From bd4fbec90fd52d7e7d8b9f040784f56bc047dbd5 Mon Sep 17 00:00:00 2001 From: marcelo Date: Mon, 23 Mar 2026 22:06:08 -0700 Subject: [PATCH 01/14] initial commit --- mcpjam-inspector/client/src/App.tsx | 44 +++ .../client/src/components/AuthTab.tsx | 2 +- .../client/src/components/PromptsTab.tsx | 2 +- .../src/components/ResourceTemplatesTab.tsx | 8 +- .../client/src/components/ResourcesTab.tsx | 10 +- .../client/src/components/ServersTab.tsx | 30 +++ .../client/src/components/SettingsTab.tsx | 2 +- .../client/src/components/TasksTab.tsx | 2 +- .../client/src/components/ToolsTab.tsx | 2 +- .../components/__tests__/PromptsTab.test.tsx | 2 +- .../__tests__/ResourceTemplatesTab.test.tsx | 2 +- .../__tests__/ResourcesTab.test.tsx | 2 +- .../components/__tests__/ServersTab.test.tsx | 77 +++++- .../components/__tests__/TasksTab.test.tsx | 2 +- .../components/__tests__/ToolsTab.test.tsx | 2 +- .../chat-v2/shared/model-helpers.ts | 2 +- .../chat-v2/thread/chatgpt-app-renderer.tsx | 46 ++-- .../__tests__/mcp-apps-renderer.test.tsx | 100 +++++++ .../thread/mcp-apps/mcp-apps-renderer.tsx | 204 +++++++++++--- .../parts/__tests__/display-modes.test.tsx | 31 +++ .../chat-v2/thread/parts/tool-part.tsx | 10 +- .../client-config/ClientConfigTab.tsx | 225 ++++++++++++++++ .../connection/ServerConnectionCard.tsx | 7 + .../connection/ServerDetailModal.tsx | 7 +- .../connection/ServerInfoContent.tsx | 12 +- .../__tests__/server-card-utils.test.ts | 2 +- .../connection/server-card-utils.ts | 2 +- .../client/src/components/mcp-sidebar.tsx | 5 + .../client/src/components/oauth/utils.ts | 2 +- .../setting/CustomProviderConfigDialog.tsx | 5 +- .../shared/DisplayContextHeader.tsx | 180 ++++++++++--- .../ui-playground/AppBuilderTab.tsx | 35 +-- .../ui-playground/PlaygroundMain.tsx | 26 +- .../ui-playground/SafeAreaEditor.tsx | 31 ++- .../__tests__/AppBuilderTab.test.tsx | 18 +- .../ui-playground/hooks/useServerKey.ts | 2 +- .../hooks/hosted/use-hosted-api-context.ts | 4 + .../client/src/hooks/use-app-state.ts | 1 + .../client/src/hooks/use-custom-providers.ts | 2 +- .../client/src/hooks/use-server-state.ts | 62 ++++- .../client/src/hooks/use-workspace-state.ts | 41 +++ .../client/src/hooks/useWorkspaces.ts | 6 + .../lib/__tests__/hosted-web-context.test.ts | 32 +++ .../client/src/lib/apis/mcp-prompts-api.ts | 2 +- .../lib/apis/mcp-resource-templates-api.ts | 2 +- .../client/src/lib/apis/mcp-tasks-api.ts | 2 +- .../client/src/lib/apis/mcp-tools-api.ts | 2 +- .../__tests__/context.guest-fallback.test.ts | 21 ++ .../client/src/lib/apis/web/context.ts | 26 +- .../client/src/lib/apis/web/servers-api.ts | 4 + .../client/src/lib/client-config.ts | 253 ++++++++++++++++++ .../client/src/lib/hosted-tab-policy.ts | 1 + .../client/src/lib/oauth/mcp-oauth.ts | 2 +- .../state/__tests__/mcp-api.hosted.test.ts | 2 +- .../client/src/state/app-types.ts | 4 +- mcpjam-inspector/client/src/state/mcp-api.ts | 5 +- .../client/src/state/server-helpers.ts | 2 +- mcpjam-inspector/client/src/state/storage.ts | 4 + .../client/src/stores/client-config-store.ts | 238 ++++++++++++++++ mcpjam-inspector/client/src/test/factories.ts | 2 +- mcpjam-inspector/server/routes/web/auth.ts | 17 +- mcpjam-inspector/server/routes/web/chat-v2.ts | 1 + .../server/routes/web/xray-payload.ts | 1 + sdk/package.json | 5 + sdk/src/browser.ts | 44 +++ sdk/src/index.ts | 7 + .../mcp-client-manager/MCPClientManager.ts | 18 +- sdk/src/mcp-client-manager/capabilities.ts | 38 +++ sdk/src/mcp-client-manager/index.ts | 7 + sdk/tests/MCPClientManager.test.ts | 72 ++++- sdk/tests/browser-entry.test.ts | 18 ++ sdk/tsup.config.ts | 2 +- 72 files changed, 1870 insertions(+), 221 deletions(-) create mode 100644 mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx create mode 100644 mcpjam-inspector/client/src/lib/client-config.ts create mode 100644 mcpjam-inspector/client/src/stores/client-config-store.ts create mode 100644 sdk/src/browser.ts create mode 100644 sdk/src/mcp-client-manager/capabilities.ts create mode 100644 sdk/tests/browser-entry.test.ts diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index b60c4c83a..319533464 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -17,6 +17,7 @@ import { ViewsTab } from "./components/ViewsTab"; import { SandboxesTab } from "./components/SandboxesTab"; import { SettingsTab } from "./components/SettingsTab"; import { WorkspaceSettingsTab } from "./components/WorkspaceSettingsTab"; +import { ClientConfigTab } from "./components/client-config/ClientConfigTab"; import { TracingTab } from "./components/TracingTab"; import { AuthTab } from "./components/AuthTab"; import { OAuthFlowTab } from "./components/OAuthFlowTab"; @@ -106,10 +107,16 @@ import { writeHostedOAuthResumeMarker, } from "./lib/hosted-oauth-resume"; import { handleOAuthCallback } from "./lib/oauth/mcp-oauth"; +import { + buildDefaultWorkspaceClientConfig, +} from "./lib/client-config"; +import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; import type { BillingRolloutState, OrganizationEntitlements, } from "./hooks/useOrganizationBilling"; +import { useClientConfigStore } from "./stores/client-config-store"; +import { useUIPlaygroundStore } from "./stores/ui-playground-store"; function getHostedOAuthCallbackErrorMessage(): string { const params = new URLSearchParams(window.location.search); @@ -354,6 +361,7 @@ export default function App() { handleSwitchWorkspace, handleCreateWorkspace, handleUpdateWorkspace, + handleUpdateClientConfig, handleDeleteWorkspace, handleWorkspaceShared, saveServerConfigWithoutConnecting, @@ -367,6 +375,11 @@ export default function App() { const { sortedOrganizations, isLoading: isLoadingOrganizations } = useOrganizationQueries({ isAuthenticated }); + const playgroundGlobals = useUIPlaygroundStore((s) => s.globals); + const playgroundCapabilities = useUIPlaygroundStore((s) => s.capabilities); + const playgroundSafeAreaInsets = useUIPlaygroundStore( + (s) => s.safeAreaInsets, + ); const currentHash = window.location.hash || "#servers"; const currentHashRoute = useMemo( () => resolveHostedNavigation(currentHash, HOSTED_MODE), @@ -423,6 +436,11 @@ export default function App() { // Get the Convex workspace ID from the active workspace const activeWorkspace = workspaces[activeWorkspaceId]; + const hostedClientCapabilities = + (activeWorkspace?.clientConfig?.clientCapabilities as + | Record + | undefined) ?? + (getDefaultClientCapabilities() as Record); const convexWorkspaceId = activeWorkspace?.sharedWorkspaceId ?? null; const rawBillingOrganizationId = activeOrganizationId ?? activeWorkspace?.organizationId ?? null; @@ -506,9 +524,28 @@ export default function App() { ), [appState.servers], ); + + useEffect(() => { + const defaultClientConfig = buildDefaultWorkspaceClientConfig({ + theme: getInitialThemeMode(), + displayMode: playgroundGlobals.displayMode, + locale: playgroundGlobals.locale, + timeZone: playgroundGlobals.timeZone, + deviceCapabilities: playgroundCapabilities, + safeAreaInsets: playgroundSafeAreaInsets, + }); + + useClientConfigStore.getState().loadWorkspaceConfig({ + workspaceId: activeWorkspaceId, + defaultConfig: defaultClientConfig, + savedConfig: activeWorkspace?.clientConfig, + }); + }, [activeWorkspaceId, activeWorkspace?.clientConfig]); + useHostedApiContext({ workspaceId: convexWorkspaceId, serverIdsByName: hostedServerIdsByName, + clientCapabilities: hostedClientCapabilities, getAccessToken, oauthTokensByServerId, guestOauthTokensByServerName, @@ -965,6 +1002,13 @@ export default function App() { serverName={appState.selectedServer} /> )} + {activeTab === "client-config" && ( + + )} {activeTab === "workspace-settings" && ( void; onReconnect: ( name: string, @@ -117,6 +121,7 @@ function SortableServerCard({
+ | undefined) ?? + (getDefaultClientCapabilities() as Record); + const reconnectWarningByServerName = useMemo( + () => + Object.fromEntries( + Object.entries(workspaceServers).map(([serverName, server]) => [ + serverName, + server.connectionStatus === "connected" && + workspaceClientCapabilitiesNeedReconnect({ + desiredCapabilities, + initializedCapabilities: + server.initializationInfo?.clientCapabilities as + | Record + | undefined, + }), + ]), + ), + [desiredCapabilities, workspaceServers], + ); const detailModalLiveServer = detailModalState.serverName ? (workspaceServers[detailModalState.serverName] ?? null) @@ -478,6 +505,7 @@ export function ServersTab({ id={name} dndDisabled={false} server={server} + needsReconnect={reconnectWarningByServerName[name]} onDisconnect={onDisconnect} onReconnect={onReconnect} onRemove={onRemove} @@ -493,6 +521,7 @@ export function ServersTab({
({ mockJsonEditor: vi.fn((props: any) => ( diff --git a/mcpjam-inspector/client/src/components/__tests__/ResourceTemplatesTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ResourceTemplatesTab.test.tsx index 6bdabec4d..2144ef65b 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ResourceTemplatesTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ResourceTemplatesTab.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { ResourceTemplatesTab } from "../ResourceTemplatesTab"; -import type { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; const { mockJsonEditor } = vi.hoisted(() => ({ mockJsonEditor: vi.fn((props: any) => ( diff --git a/mcpjam-inspector/client/src/components/__tests__/ResourcesTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ResourcesTab.test.tsx index f9a94d52d..98b1047c9 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ResourcesTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ResourcesTab.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { ResourcesTab } from "../ResourcesTab"; -import type { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; const { mockJsonEditor } = vi.hoisted(() => ({ mockJsonEditor: vi.fn((props: any) => ( diff --git a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx index c1bbc424b..816010f2a 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { useState, type ReactNode } from "react"; +import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; import type { ServerWithName, ServerUpdateResult } from "@/hooks/use-app-state"; import type { Workspace } from "@/state/app-types"; import type { ServerFormData } from "@/shared/types.js"; @@ -43,14 +44,19 @@ vi.mock("@/hooks/useWorkspaces", () => ({ vi.mock("../connection/ServerConnectionCard", () => ({ ServerConnectionCard: ({ server, + needsReconnect, onOpenDetailModal, }: { server: ServerWithName; + needsReconnect?: boolean; onOpenDetailModal?: (server: ServerWithName, defaultTab: string) => void; }) => ( - +
+ + {needsReconnect ? Needs reconnect : null} +
), })); @@ -376,4 +382,69 @@ describe("ServersTab shared detail modal", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + + it("surfaces reconnect warnings when workspace client capabilities changed", () => { + const initializedCapabilities = + getDefaultClientCapabilities() as Record; + + const { rerender } = render( + , + ); + + expect(screen.queryByText("Needs reconnect")).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText("Needs reconnect")).toBeInTheDocument(); + }); }); diff --git a/mcpjam-inspector/client/src/components/__tests__/TasksTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/TasksTab.test.tsx index 73a9cd4bd..1b7c42769 100644 --- a/mcpjam-inspector/client/src/components/__tests__/TasksTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/TasksTab.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { TasksTab } from "../TasksTab"; -import type { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; const { mockJsonEditor } = vi.hoisted(() => ({ mockJsonEditor: vi.fn((props: any) => ( diff --git a/mcpjam-inspector/client/src/components/__tests__/ToolsTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ToolsTab.test.tsx index 9861585c9..3bf86af30 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ToolsTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ToolsTab.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { ToolsTab } from "../ToolsTab"; -import type { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; // Mock posthog vi.mock("posthog-js/react", () => ({ diff --git a/mcpjam-inspector/client/src/components/chat-v2/shared/model-helpers.ts b/mcpjam-inspector/client/src/components/chat-v2/shared/model-helpers.ts index 6be168205..76534eb89 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/shared/model-helpers.ts +++ b/mcpjam-inspector/client/src/components/chat-v2/shared/model-helpers.ts @@ -6,7 +6,7 @@ import { isMCPJamProvidedModel, Model, } from "@/shared/types"; -import type { CustomProvider } from "@mcpjam/sdk"; +import type { CustomProvider } from "@mcpjam/sdk/browser"; export function parseModelAliases( aliasString: string, diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx index a2b7ba057..eee73f125 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx @@ -39,6 +39,13 @@ import { loadLocalChatGptWidget, type WidgetCspData, } from "./chatgpt-widget-loaders"; +import { useClientConfigStore } from "@/stores/client-config-store"; +import { + extractHostDeviceCapabilities, + extractHostLocale, + extractHostSafeAreaInsets, + extractHostTheme, +} from "@/lib/client-config"; type ToolState = | "input-streaming" @@ -618,9 +625,14 @@ export function ChatGPTAppRenderer({ const rootRef = useRef(null); const inlineWidthRef = useRef(undefined); const themeMode = usePreferencesStore((s) => s.themeMode); + const draftHostContext = useClientConfigStore((s) => s.draftConfig?.hostContext); // Get locale from playground store, fallback to navigator.language const playgroundLocale = useUIPlaygroundStore((s) => s.globals.locale); - const locale = playgroundLocale || navigator.language || "en-US"; + const locale = extractHostLocale( + draftHostContext, + playgroundLocale || navigator.language || "en-US", + ); + const resolvedTheme = extractHostTheme(draftHostContext) ?? themeMode; const { resolvedToolCallId, @@ -734,12 +746,14 @@ export function ChatGPTAppRenderer({ ? playgroundDeviceType : getDeviceType(); // Use stable default objects to avoid infinite re-renders in useWidgetFetch - const capabilities = isPlaygroundActive - ? playgroundCapabilities - : DEFAULT_CAPABILITIES; - const safeAreaInsets = isPlaygroundActive - ? playgroundSafeAreaInsets - : DEFAULT_SAFE_AREA_INSETS; + const capabilities = extractHostDeviceCapabilities( + draftHostContext, + isPlaygroundActive ? playgroundCapabilities : DEFAULT_CAPABILITIES, + ); + const safeAreaInsets = extractHostSafeAreaInsets( + draftHostContext, + isPlaygroundActive ? playgroundSafeAreaInsets : DEFAULT_SAFE_AREA_INSETS, + ); const setWidgetCsp = useWidgetDebugStore((s) => s.setWidgetCsp); const setWidgetHtml = useWidgetDebugStore((s) => s.setWidgetHtml); const clearCspViolations = useWidgetDebugStore((s) => s.clearCspViolations); @@ -803,7 +817,7 @@ export function ChatGPTAppRenderer({ resolvedToolInput, resolvedToolOutput, toolResponseMetadata, - themeMode, + resolvedTheme, locale, cspMode, deviceType, @@ -886,7 +900,7 @@ export function ChatGPTAppRenderer({ widgetState: initialWidgetState ?? null, prefersBorder, globals: { - theme: themeMode, + theme: resolvedTheme, displayMode: effectiveDisplayMode, maxHeight: maxHeight ?? undefined, locale, @@ -901,7 +915,7 @@ export function ChatGPTAppRenderer({ resolvedToolCallId, toolName, setWidgetDebugInfo, - themeMode, + resolvedTheme, effectiveDisplayMode, maxHeight, locale, @@ -914,13 +928,13 @@ export function ChatGPTAppRenderer({ useEffect(() => { setWidgetGlobals(resolvedToolCallId, { - theme: themeMode, + theme: resolvedTheme, displayMode: effectiveDisplayMode, maxHeight: maxHeight ?? undefined, }); }, [ resolvedToolCallId, - themeMode, + resolvedTheme, effectiveDisplayMode, maxHeight, setWidgetGlobals, @@ -1377,7 +1391,7 @@ export function ChatGPTAppRenderer({ // Widget state is loaded from localStorage by widget-runtime initialization // Push current globals const globals: Record = { - theme: themeMode, + theme: resolvedTheme, displayMode: "inline", maxHeight: null, locale, @@ -1397,7 +1411,7 @@ export function ChatGPTAppRenderer({ globals, }); }, [ - themeMode, + resolvedTheme, locale, deviceType, capabilities, @@ -1434,7 +1448,7 @@ export function ChatGPTAppRenderer({ useEffect(() => { if (!isReady) return; const globals: Record = { - theme: themeMode, + theme: resolvedTheme, displayMode: effectiveDisplayMode, locale, safeArea: { insets: safeAreaInsets }, @@ -1454,7 +1468,7 @@ export function ChatGPTAppRenderer({ postToWidget({ type: "openai:set_globals", globals }); if (modalOpen) postToWidget({ type: "openai:set_globals", globals }, true); }, [ - themeMode, + resolvedTheme, maxHeight, effectiveDisplayMode, locale, diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx index 8a3c2bcc9..6f2da7c25 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx @@ -68,6 +68,16 @@ const { }; }); +const mockClientConfigStoreState = { + draftConfig: undefined as + | { + version: 1; + clientCapabilities: Record; + hostContext: Record; + } + | undefined, +}; + // ── Module mocks ─────────────────────────────────────────────────────────── vi.mock("@modelcontextprotocol/ext-apps/app-bridge", () => ({ AppBridge: vi.fn().mockImplementation(() => mockBridge), @@ -125,6 +135,10 @@ vi.mock("@/stores/ui-playground-store", () => ({ }), })); +vi.mock("@/stores/client-config-store", () => ({ + useClientConfigStore: (selector: any) => selector(mockClientConfigStoreState), +})); + vi.mock("@/stores/traffic-log-store", () => ({ useTrafficLogStore: (selector: any) => selector({ addLog: stableStoreFns.addLog }), @@ -186,6 +200,7 @@ const baseProps = { describe("MCPAppsRenderer tool input streaming", () => { beforeEach(() => { vi.clearAllMocks(); + mockClientConfigStoreState.draftConfig = undefined; mockBridge.sendToolInput.mockClear(); mockBridge.sendToolInputPartial.mockClear(); mockBridge.sendToolResult.mockClear(); @@ -283,6 +298,91 @@ describe("MCPAppsRenderer tool input streaming", () => { }); }); + it("clamps configured host display modes before sending host context", async () => { + mockClientConfigStoreState.draftConfig = { + version: 1, + clientCapabilities: {}, + hostContext: { + displayMode: "fullscreen", + availableDisplayModes: ["inline"], + locale: "fr-FR", + timeZone: "Europe/Paris", + }, + }; + + render(); + + await vi.waitFor(() => { + expect(mockBridge.connect).toHaveBeenCalled(); + }); + + await act(async () => { + triggerReady(); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(mockBridge.setHostContext).toHaveBeenCalledWith( + expect.objectContaining({ + displayMode: "inline", + availableDisplayModes: ["inline"], + locale: "fr-FR", + timeZone: "Europe/Paris", + }), + ); + }); + }); + + it("pushes updated host context when the workspace client profile changes", async () => { + const { rerender } = render(); + + await vi.waitFor(() => { + expect(mockBridge.connect).toHaveBeenCalled(); + }); + + await act(async () => { + triggerReady(); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(mockBridge.setHostContext).toHaveBeenCalledWith( + expect.objectContaining({ + locale: "en-US", + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }), + ); + }); + + mockClientConfigStoreState.draftConfig = { + version: 1, + clientCapabilities: {}, + hostContext: { + locale: "es-ES", + timeZone: "Europe/Madrid", + deviceCapabilities: { + hover: false, + touch: true, + }, + }, + }; + + rerender(); + + await vi.waitFor(() => { + expect(mockBridge.setHostContext).toHaveBeenLastCalledWith( + expect.objectContaining({ + locale: "es-ES", + timeZone: "Europe/Madrid", + deviceCapabilities: { + hover: false, + touch: true, + }, + }), + ); + }); + }); + it("forces permissive replay for cached HTML even when strict replay metadata is stored", async () => { render( (null); const themeMode = usePreferencesStore((s) => s.themeMode); const sandboxHostStyle = useSandboxHostStyle(); + const draftHostContext = useClientConfigStore((s) => s.draftConfig?.hostContext); + const baseHostContext = useMemo( + () => + draftHostContext && + typeof draftHostContext === "object" && + !Array.isArray(draftHostContext) + ? draftHostContext + : {}, + [draftHostContext], + ); + const resolvedTheme = extractHostTheme(baseHostContext) ?? themeMode; // Get CSP mode and host style from playground store when in playground const isPlaygroundActive = useUIPlaygroundStore((s) => s.isPlaygroundActive); @@ -186,24 +204,58 @@ export function MCPAppsRenderer({ // Get locale and timeZone from playground store when active, fallback to browser defaults const playgroundLocale = useUIPlaygroundStore((s) => s.globals.locale); const playgroundTimeZone = useUIPlaygroundStore((s) => s.globals.timeZone); - const locale = isPlaygroundActive + const fallbackLocale = isPlaygroundActive ? playgroundLocale : navigator.language || "en-US"; - const timeZone = isPlaygroundActive + const fallbackTimeZone = isPlaygroundActive ? playgroundTimeZone : Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const locale = + typeof draftHostContext?.locale === "string" + ? draftHostContext.locale + : fallbackLocale; + const timeZone = + typeof draftHostContext?.timeZone === "string" + ? draftHostContext.timeZone + : fallbackTimeZone; // Get displayMode from playground store when active (SEP-1865) const playgroundDisplayMode = useUIPlaygroundStore((s) => s.displayMode); + const configuredDisplayMode = useMemo( + () => extractHostDisplayMode(draftHostContext), + [draftHostContext], + ); + const configuredAvailableDisplayModes = useMemo( + () => extractHostDisplayModes(draftHostContext), + [draftHostContext], + ); // Get device capabilities from playground store (SEP-1865) const playgroundCapabilities = useUIPlaygroundStore((s) => s.capabilities); const deviceCapabilities = useMemo( - () => - isPlaygroundActive + () => { + const configuredCapabilities = + draftHostContext?.deviceCapabilities && + typeof draftHostContext.deviceCapabilities === "object" && + !Array.isArray(draftHostContext.deviceCapabilities) + ? (draftHostContext.deviceCapabilities as { + hover?: boolean; + touch?: boolean; + }) + : undefined; + + if (configuredCapabilities) { + return { + hover: configuredCapabilities.hover ?? true, + touch: configuredCapabilities.touch ?? false, + }; + } + + return isPlaygroundActive ? playgroundCapabilities - : { hover: true, touch: false }, // Desktop defaults - [isPlaygroundActive, playgroundCapabilities], + : { hover: true, touch: false }; + }, + [draftHostContext, isPlaygroundActive, playgroundCapabilities], ); // Get safe area insets from playground store (SEP-1865) @@ -211,11 +263,33 @@ export function MCPAppsRenderer({ (s) => s.safeAreaInsets, ); const safeAreaInsets = useMemo( - () => - isPlaygroundActive + () => { + const configuredSafeAreaInsets = + draftHostContext?.safeAreaInsets && + typeof draftHostContext.safeAreaInsets === "object" && + !Array.isArray(draftHostContext.safeAreaInsets) + ? (draftHostContext.safeAreaInsets as { + top?: number; + right?: number; + bottom?: number; + left?: number; + }) + : undefined; + + if (configuredSafeAreaInsets) { + return { + top: configuredSafeAreaInsets.top ?? 0, + right: configuredSafeAreaInsets.right ?? 0, + bottom: configuredSafeAreaInsets.bottom ?? 0, + left: configuredSafeAreaInsets.left ?? 0, + }; + } + + return isPlaygroundActive ? playgroundSafeAreaInsets - : { top: 0, right: 0, bottom: 0, left: 0 }, - [isPlaygroundActive, playgroundSafeAreaInsets], + : { top: 0, right: 0, bottom: 0, left: 0 }; + }, + [draftHostContext, isPlaygroundActive, playgroundSafeAreaInsets], ); // Get device type from playground store for platform derivation (SEP-1865) @@ -224,16 +298,27 @@ export function MCPAppsRenderer({ // Display mode: controlled (via props) or uncontrolled (internal state) const isControlled = displayModeProp !== undefined; const [internalDisplayMode, setInternalDisplayMode] = useState( - isPlaygroundActive ? playgroundDisplayMode : "inline", + clampDisplayModeToAvailableModes( + configuredDisplayMode ?? (isPlaygroundActive ? playgroundDisplayMode : "inline"), + configuredAvailableDisplayModes, + ), ); const displayMode = isControlled ? displayModeProp : internalDisplayMode; - const effectiveDisplayMode = useMemo(() => { + const requestedDisplayMode = useMemo(() => { if (!isControlled) return displayMode; if (displayMode === "fullscreen" && fullscreenWidgetId === toolCallId) return "fullscreen"; if (displayMode === "pip" && pipWidgetId === toolCallId) return "pip"; return "inline"; }, [displayMode, fullscreenWidgetId, isControlled, pipWidgetId, toolCallId]); + const effectiveDisplayMode = useMemo( + () => + clampDisplayModeToAvailableModes( + requestedDisplayMode, + configuredAvailableDisplayModes, + ), + [configuredAvailableDisplayModes, requestedDisplayMode], + ); const setDisplayMode = useCallback( (mode: DisplayMode) => { if (isControlled) { @@ -258,6 +343,21 @@ export function MCPAppsRenderer({ displayMode, ], ); + const lastForcedDisplayModeRef = useRef(null); + + useEffect(() => { + if (requestedDisplayMode === effectiveDisplayMode) { + lastForcedDisplayModeRef.current = null; + return; + } + + if (lastForcedDisplayModeRef.current === effectiveDisplayMode) { + return; + } + + lastForcedDisplayModeRef.current = effectiveDisplayMode; + setDisplayMode(effectiveDisplayMode); + }, [effectiveDisplayMode, requestedDisplayMode, setDisplayMode]); const [isReady, setIsReady] = useState(false); const [reinitCount, setReinitCount] = useState(0); @@ -341,7 +441,6 @@ export function MCPAppsRenderer({ const toolOutputRef = useRef(toolOutput); toolOutputRef.current = toolOutput; const themeModeRef = useRef(themeMode); - themeModeRef.current = themeMode; const { canRenderStreamingInput, @@ -520,9 +619,20 @@ export function MCPAppsRenderer({ // Only sync when not in controlled mode (parent controls displayMode via props) useEffect(() => { if (isPlaygroundActive && !isControlled) { - setInternalDisplayMode(playgroundDisplayMode); + setInternalDisplayMode( + clampDisplayModeToAvailableModes( + configuredDisplayMode ?? playgroundDisplayMode, + configuredAvailableDisplayModes, + ), + ); } - }, [isPlaygroundActive, playgroundDisplayMode, isControlled]); + }, [ + configuredAvailableDisplayModes, + configuredDisplayMode, + isPlaygroundActive, + playgroundDisplayMode, + isControlled, + ]); // Initialize widget debug info useEffect(() => { @@ -532,7 +642,7 @@ export function MCPAppsRenderer({ widgetState: null, // MCP Apps don't have widget state in the same way prefersBorder, globals: { - theme: themeMode, + theme: resolvedTheme, displayMode: effectiveDisplayMode, locale, timeZone, @@ -544,7 +654,7 @@ export function MCPAppsRenderer({ toolCallId, toolName, setWidgetDebugInfo, - themeMode, + resolvedTheme, effectiveDisplayMode, locale, timeZone, @@ -556,7 +666,7 @@ export function MCPAppsRenderer({ // Update globals in debug store when they change useEffect(() => { setWidgetGlobals(toolCallId, { - theme: themeMode, + theme: resolvedTheme, displayMode: effectiveDisplayMode, locale, timeZone, @@ -565,7 +675,7 @@ export function MCPAppsRenderer({ }); }, [ toolCallId, - themeMode, + resolvedTheme, effectiveDisplayMode, locale, timeZone, @@ -580,33 +690,52 @@ export function MCPAppsRenderer({ ? playgroundHostStyle : (sandboxHostStyle ?? "claude"); const useChatGPTStyle = effectiveHostStyle === "chatgpt"; + themeModeRef.current = resolvedTheme; const styleVariables = useMemo( () => useChatGPTStyle - ? getChatGPTStyleVariables(themeMode) - : getClaudeDesktopStyleVariables(themeMode), - [themeMode, useChatGPTStyle], + ? getChatGPTStyleVariables(resolvedTheme) + : getClaudeDesktopStyleVariables(resolvedTheme), + [resolvedTheme, useChatGPTStyle], ); // containerDimensions (maxWidth/maxHeight) was previously sent here but // removed — width is now fully host-controlled. const hostContext = useMemo( () => ({ - theme: themeMode, + ...baseHostContext, + theme: + baseHostContext.theme === "light" || baseHostContext.theme === "dark" + ? baseHostContext.theme + : resolvedTheme, displayMode: effectiveDisplayMode, - availableDisplayModes: ["inline", "pip", "fullscreen"], + availableDisplayModes: configuredAvailableDisplayModes, locale, timeZone, - platform: useChatGPTStyle ? CHATGPT_PLATFORM : CLAUDE_DESKTOP_PLATFORM, + platform: + baseHostContext.platform === "web" || + baseHostContext.platform === "desktop" || + baseHostContext.platform === "mobile" + ? baseHostContext.platform + : useChatGPTStyle + ? CHATGPT_PLATFORM + : CLAUDE_DESKTOP_PLATFORM, userAgent: navigator.userAgent, deviceCapabilities, safeAreaInsets, - styles: { - variables: styleVariables, - css: { - fonts: useChatGPTStyle ? CHATGPT_FONT_CSS : CLAUDE_DESKTOP_FONT_CSS, - }, - }, + styles: + baseHostContext.styles && + typeof baseHostContext.styles === "object" && + !Array.isArray(baseHostContext.styles) + ? (baseHostContext.styles as McpUiHostContext["styles"]) + : { + variables: styleVariables, + css: { + fonts: useChatGPTStyle + ? CHATGPT_FONT_CSS + : CLAUDE_DESKTOP_FONT_CSS, + }, + }, toolInfo: { id: toolCallId, tool: { @@ -622,8 +751,10 @@ export function MCPAppsRenderer({ }, }), [ - themeMode, + baseHostContext, + resolvedTheme, effectiveDisplayMode, + configuredAvailableDisplayModes, locale, timeZone, deviceCapabilities, @@ -830,13 +961,20 @@ export function MCPAppsRenderer({ bridge.onrequestdisplaymode = async ({ mode }) => { const requestedMode = mode ?? "inline"; + const hostAvailableModes = extractHostDisplayModes( + hostContextRef.current as Record | undefined, + ); // Use device type for mobile detection (defaults to mobile-like behavior when not in playground) const isMobile = isPlaygroundActiveRef.current ? playgroundDeviceTypeRef.current === "mobile" || playgroundDeviceTypeRef.current === "tablet" : true; - const actualMode: DisplayMode = + const mobileAdjustedMode: DisplayMode = isMobile && requestedMode === "pip" ? "fullscreen" : requestedMode; + const actualMode = clampDisplayModeToAvailableModes( + mobileAdjustedMode, + hostAvailableModes, + ); setDisplayModeRef.current(actualMode); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx index 6681461c5..18e115182 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx @@ -44,6 +44,20 @@ vi.mock("@/stores/widget-debug-store", () => ({ }), })); +const mockClientConfigStoreState = { + draftConfig: undefined as + | { + hostContext?: { + availableDisplayModes?: ("inline" | "pip" | "fullscreen")[]; + }; + } + | undefined, +}; + +vi.mock("@/stores/client-config-store", () => ({ + useClientConfigStore: (selector: any) => selector(mockClientConfigStoreState), +})); + // Mock thread-helpers vi.mock("../../thread-helpers", () => ({ getToolNameFromType: () => "test-tool", @@ -90,6 +104,7 @@ describe("ToolPart display mode controls", () => { beforeEach(() => { vi.clearAllMocks(); onDisplayModeChange = vi.fn(); + mockClientConfigStoreState.draftConfig = undefined; }); const renderWithDisplayModes = ( @@ -158,6 +173,22 @@ describe("ToolPart display mode controls", () => { expect(disabledButtons).toHaveLength(0); }); + it("disables modes that the host does not advertise even when the app supports them", () => { + mockClientConfigStoreState.draftConfig = { + hostContext: { + availableDisplayModes: ["inline"], + }, + }; + + renderWithDisplayModes(["inline", "pip", "fullscreen"]); + + const disabledButtons = screen.getAllByRole("button").filter((b) => b.disabled); + expect(disabledButtons).toHaveLength(2); + expect(screen.getByLabelText("Inline")).not.toBeDisabled(); + expect(screen.getByLabelText("PiP")).toBeDisabled(); + expect(screen.getByLabelText("Fullscreen")).toBeDisabled(); + }); + it("allows clicking enabled display mode buttons", async () => { renderWithDisplayModes(["inline", "pip"]); const user = userEvent.setup(); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx index 0182b4420..934bf4bd2 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx @@ -38,6 +38,8 @@ import { CspDebugPanel } from "../csp-debug-panel"; import { JsonEditor } from "@/components/ui/json-editor"; import { cn } from "@/lib/chat-utils"; import { TextPart } from "./text-part"; +import { useClientConfigStore } from "@/stores/client-config-store"; +import { extractHostDisplayModes } from "@/lib/client-config"; type ApprovalVisualState = "pending" | "approved" | "denied"; type TraceDisplayMode = "markdown" | "json-markdown"; @@ -155,6 +157,9 @@ export function ToolPart({ const widgetDebugInfo = useWidgetDebugStore((s) => toolCallId ? s.widgets.get(toolCallId) : undefined, ); + const hostAvailableDisplayModes = useClientConfigStore((s) => + extractHostDisplayModes(s.draftConfig?.hostContext), + ); const hasWidgetDebug = !!widgetDebugInfo; const hasWidgetDebugUI = !hideDiagnosticsUI && hasWidgetDebug; @@ -275,8 +280,9 @@ export function ToolPart({ displayModeOptions.map(({ mode, icon: Icon }) => { const isActive = displayMode === mode; const isDisabled = - appSupportedDisplayModes !== undefined && - !appSupportedDisplayModes.includes(mode); + !hostAvailableDisplayModes.includes(mode) || + (appSupportedDisplayModes !== undefined && + !appSupportedDisplayModes.includes(mode)); const buttonLabel = mode === "inline" ? "Inline" : mode === "pip" ? "PiP" : "Fullscreen"; return ( diff --git a/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx b/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx new file mode 100644 index 000000000..f6ae9a0d6 --- /dev/null +++ b/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx @@ -0,0 +1,225 @@ +import { useMemo } from "react"; +import { toast } from "sonner"; +import { AlertTriangle, RefreshCcw, Save } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { JsonEditor } from "@/components/ui/json-editor"; +import type { Workspace } from "@/state/app-types"; +import { + workspaceClientCapabilitiesNeedReconnect, + type WorkspaceClientConfig, +} from "@/lib/client-config"; +import { useClientConfigStore } from "@/stores/client-config-store"; + +interface ClientConfigTabProps { + activeWorkspaceId: string; + workspace?: Workspace; + onSaveClientConfig: ( + workspaceId: string, + clientConfig: WorkspaceClientConfig | undefined, + ) => Promise; +} + +export function ClientConfigTab({ + activeWorkspaceId, + workspace, + onSaveClientConfig, +}: ClientConfigTabProps) { + const defaultConfig = useClientConfigStore((s) => s.defaultConfig); + const draftConfig = useClientConfigStore((s) => s.draftConfig); + const clientCapabilitiesText = useClientConfigStore( + (s) => s.clientCapabilitiesText, + ); + const hostContextText = useClientConfigStore((s) => s.hostContextText); + const clientCapabilitiesError = useClientConfigStore( + (s) => s.clientCapabilitiesError, + ); + const hostContextError = useClientConfigStore((s) => s.hostContextError); + const isDirty = useClientConfigStore((s) => s.isDirty); + const isSaving = useClientConfigStore((s) => s.isSaving); + const setSectionText = useClientConfigStore((s) => s.setSectionText); + const resetSectionToDefault = useClientConfigStore( + (s) => s.resetSectionToDefault, + ); + const resetToBaseline = useClientConfigStore((s) => s.resetToBaseline); + const markSaving = useClientConfigStore((s) => s.markSaving); + const markSaved = useClientConfigStore((s) => s.markSaved); + + const desiredCapabilities = + workspace?.clientConfig?.clientCapabilities ?? + defaultConfig?.clientCapabilities ?? + {}; + + const reconnectServers = useMemo(() => { + if (!workspace) { + return []; + } + + return Object.values(workspace.servers).filter((server) => { + if (server.connectionStatus !== "connected") { + return false; + } + + return workspaceClientCapabilitiesNeedReconnect({ + desiredCapabilities, + initializedCapabilities: + server.initializationInfo?.clientCapabilities as + | Record + | undefined, + }); + }); + }, [desiredCapabilities, workspace]); + + const handleSave = async () => { + if (!draftConfig) { + return; + } + if (clientCapabilitiesError || hostContextError) { + toast.error("Fix JSON validation errors before saving."); + return; + } + + markSaving(true); + try { + await onSaveClientConfig(activeWorkspaceId, draftConfig); + markSaved(draftConfig); + toast.success("Workspace client profile saved."); + } catch (error) { + markSaving(false); + toast.error( + error instanceof Error ? error.message : "Failed to save client profile.", + ); + } + }; + + return ( +
+
+
+
+
+

+ Client Config +

+

+ Applies to the active workspace only. +

+
+
+ {isDirty ? ( + Unsaved changes + ) : ( + Saved + )} + + +
+
+ + {reconnectServers.length > 0 ? ( + +
+ +
+
+ Needs reconnect +
+

+ Saved client capabilities differ from the last initialize + payload for:{" "} + {reconnectServers.map((server) => server.name).join(", ")}. +

+
+
+
+ ) : null} +
+ +
+ +
+
+
clientCapabilities
+

+ Sent on the next connect or reconnect. +

+
+ +
+ {clientCapabilitiesError ? ( +
+ {clientCapabilitiesError} +
+ ) : null} +
+ + setSectionText("clientCapabilities", value) + } + mode="edit" + showModeToggle={false} + className="h-full border" + height="100%" + wrapLongLinesInEdit={false} + showLineNumbers + /> +
+
+ + +
+
+
hostContext
+

+ Applied live to mounted MCP Apps widgets. +

+
+ +
+ {hostContextError ? ( +
+ {hostContextError} +
+ ) : null} +
+ setSectionText("hostContext", value)} + mode="edit" + showModeToggle={false} + className="h-full border" + height="100%" + wrapLongLinesInEdit={false} + showLineNumbers + /> +
+
+
+
+
+ ); +} diff --git a/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx b/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx index 6214121d1..146ca33c9 100644 --- a/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx +++ b/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx @@ -66,6 +66,7 @@ function isHostedInsecureHttpServer(server: ServerWithName): boolean { interface ServerConnectionCardProps { server: ServerWithName; + needsReconnect?: boolean; onDisconnect: (serverName: string) => void; onReconnect: ( serverName: string, @@ -82,6 +83,7 @@ interface ServerConnectionCardProps { export function ServerConnectionCard({ server, + needsReconnect = false, onDisconnect, onReconnect, onRemove, @@ -376,6 +378,11 @@ export function ServerConnectionCard({

{server.name}

+ {needsReconnect ? ( + + Needs reconnect + + ) : null} {version && ( v{version} diff --git a/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx b/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx index 88841bbe1..524f725f1 100644 --- a/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx +++ b/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx @@ -37,6 +37,7 @@ interface ServerDetailModalProps { isOpen: boolean; onClose: () => void; server: ServerWithName; + needsReconnect?: boolean; defaultTab?: ServerDetailTab; onSubmit: ( formData: ServerFormData, @@ -54,6 +55,7 @@ export function ServerDetailModal({ isOpen, onClose, server, + needsReconnect = false, defaultTab = "overview", onSubmit, onDisconnect, @@ -383,7 +385,10 @@ export function ServerDetailModal({ Connect to view server overview
) : ( - + )}
diff --git a/mcpjam-inspector/client/src/components/connection/ServerInfoContent.tsx b/mcpjam-inspector/client/src/components/connection/ServerInfoContent.tsx index ba1be09dc..ea2ef6e38 100644 --- a/mcpjam-inspector/client/src/components/connection/ServerInfoContent.tsx +++ b/mcpjam-inspector/client/src/components/connection/ServerInfoContent.tsx @@ -13,9 +13,13 @@ import { ScrollableJsonView } from "@/components/ui/json-editor"; interface ServerInfoContentProps { server: ServerWithName; + needsReconnect?: boolean; } -export function ServerInfoContent({ server }: ServerInfoContentProps) { +export function ServerInfoContent({ + server, + needsReconnect = false, +}: ServerInfoContentProps) { const [copiedField, setCopiedField] = useState(null); const [expandedTokens, setExpandedTokens] = useState>(new Set()); @@ -193,6 +197,12 @@ export function ServerInfoContent({ server }: ServerInfoContentProps) { return (
+ {needsReconnect ? ( +
+ Saved client capabilities differ from this server's last initialize + payload. Reconnect the server to apply the workspace client profile. +
+ ) : null} {serverName && (
diff --git a/mcpjam-inspector/client/src/components/connection/__tests__/server-card-utils.test.ts b/mcpjam-inspector/client/src/components/connection/__tests__/server-card-utils.test.ts index 20ddc5f84..6f81d609c 100644 --- a/mcpjam-inspector/client/src/components/connection/__tests__/server-card-utils.test.ts +++ b/mcpjam-inspector/client/src/components/connection/__tests__/server-card-utils.test.ts @@ -4,7 +4,7 @@ import { getServerCommandDisplay, getServerTransportLabel, } from "../server-card-utils.js"; -import type { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; import type { ConnectionStatus } from "@/state/app-types"; describe("getConnectionStatusMeta", () => { diff --git a/mcpjam-inspector/client/src/components/connection/server-card-utils.ts b/mcpjam-inspector/client/src/components/connection/server-card-utils.ts index 1ce3cba0a..4d453fe48 100644 --- a/mcpjam-inspector/client/src/components/connection/server-card-utils.ts +++ b/mcpjam-inspector/client/src/components/connection/server-card-utils.ts @@ -1,6 +1,6 @@ import type { ComponentType } from "react"; import { Check, Loader2, Wifi, X } from "lucide-react"; -import type { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; import type { ConnectionStatus } from "@/state/app-types"; interface ConnectionStatusMeta { diff --git a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx index 054335182..a131d6012 100644 --- a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx +++ b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx @@ -146,6 +146,11 @@ const navigationSections: NavSection[] = [ url: "#views", icon: Layers, }, + { + title: "Client Config", + url: "#client-config", + icon: Settings, + }, { title: "Generate Evals", url: "#evals", diff --git a/mcpjam-inspector/client/src/components/oauth/utils.ts b/mcpjam-inspector/client/src/components/oauth/utils.ts index 6f914dae9..c5c91a576 100644 --- a/mcpjam-inspector/client/src/components/oauth/utils.ts +++ b/mcpjam-inspector/client/src/components/oauth/utils.ts @@ -1,5 +1,5 @@ import type { ServerWithName } from "@/hooks/use-app-state"; -import type { HttpServerConfig } from "@mcpjam/sdk"; +import type { HttpServerConfig } from "@mcpjam/sdk/browser"; import { EMPTY_OAUTH_TEST_PROFILE, type OAuthTestProfile, diff --git a/mcpjam-inspector/client/src/components/setting/CustomProviderConfigDialog.tsx b/mcpjam-inspector/client/src/components/setting/CustomProviderConfigDialog.tsx index 4b52abf25..82fcf60a0 100644 --- a/mcpjam-inspector/client/src/components/setting/CustomProviderConfigDialog.tsx +++ b/mcpjam-inspector/client/src/components/setting/CustomProviderConfigDialog.tsx @@ -1,5 +1,8 @@ import { useState, useEffect } from "react"; -import type { CustomProvider, CompatibleProtocol } from "@mcpjam/sdk"; +import type { + CompatibleProtocol, + CustomProvider, +} from "@mcpjam/sdk/browser"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { diff --git a/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx b/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx index d7fde3aa3..8834af36a 100644 --- a/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx +++ b/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx @@ -42,11 +42,20 @@ import { type CspMode, } from "@/stores/ui-playground-store"; import { useWidgetDebugStore } from "@/stores/widget-debug-store"; -import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; import { cn } from "@/lib/utils"; -import { updateThemeMode } from "@/lib/theme-utils"; import { SafeAreaEditor } from "@/components/ui-playground/SafeAreaEditor"; import { UIType } from "@/lib/mcp-ui/mcp-apps-utils"; +import { useClientConfigStore } from "@/stores/client-config-store"; +import { + clampDisplayModeToAvailableModes, + extractEffectiveHostDisplayMode, + extractHostDeviceCapabilities, + extractHostDisplayModes, + extractHostLocale, + extractHostTheme, + extractHostTimeZone, + type HostDisplayMode, +} from "@/lib/client-config"; /** Device frame configurations - extends shared viewport config with UI properties */ export const PRESET_DEVICE_CONFIGS: Record< @@ -152,16 +161,13 @@ export function DisplayContextHeader({ const [localePopoverOpen, setLocalePopoverOpen] = useState(false); const [cspPopoverOpen, setCspPopoverOpen] = useState(false); const [timezonePopoverOpen, setTimezonePopoverOpen] = useState(false); + const [displayModesPopoverOpen, setDisplayModesPopoverOpen] = useState(false); // Store state const deviceType = useUIPlaygroundStore((s) => s.deviceType); const setDeviceType = useUIPlaygroundStore((s) => s.setDeviceType); const customViewport = useUIPlaygroundStore((s) => s.customViewport); const setCustomViewport = useUIPlaygroundStore((s) => s.setCustomViewport); - const globals = useUIPlaygroundStore((s) => s.globals); - const updateGlobal = useUIPlaygroundStore((s) => s.updateGlobal); - const capabilities = useUIPlaygroundStore((s) => s.capabilities); - const setCapabilities = useUIPlaygroundStore((s) => s.setCapabilities); // Host style (Claude / ChatGPT) const hostStyle = useUIPlaygroundStore((s) => s.hostStyle); @@ -174,6 +180,8 @@ export function DisplayContextHeader({ // CSP mode for MCP Apps (SEP-1865) const mcpAppsCspMode = useUIPlaygroundStore((s) => s.mcpAppsCspMode); const setMcpAppsCspMode = useUIPlaygroundStore((s) => s.setMcpAppsCspMode); + const hostContext = useClientConfigStore((s) => s.draftConfig?.hostContext); + const patchHostContext = useClientConfigStore((s) => s.patchHostContext); // Protocol-aware CSP mode const activeCspMode = protocol === UIType.MCP_APPS ? mcpAppsCspMode : cspMode; @@ -197,15 +205,15 @@ export function DisplayContextHeader({ prevViolationCount.current = violationCount; }, [violationCount]); - // Theme handling - const themeMode = usePreferencesStore((s) => s.themeMode); - const setThemeMode = usePreferencesStore((s) => s.setThemeMode); + const fallbackLocale = navigator.language || "en-US"; + const fallbackTimeZone = + Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const theme = extractHostTheme(hostContext) ?? "dark"; const handleThemeChange = useCallback(() => { - const newTheme = themeMode === "dark" ? "light" : "dark"; - updateThemeMode(newTheme); - setThemeMode(newTheme); - }, [themeMode, setThemeMode]); + const newTheme = theme === "dark" ? "light" : "dark"; + patchHostContext({ theme: newTheme }); + }, [theme, patchHostContext]); // Device config - use custom dimensions from store for custom type const deviceConfig = useMemo(() => { @@ -220,9 +228,50 @@ export function DisplayContextHeader({ }, [deviceType, customViewport]); const DeviceIcon = deviceConfig.icon; - // Locale and timezone from globals - const locale = globals.locale; - const timeZone = globals.timeZone; + // Host display context comes directly from hostContext. + const locale = extractHostLocale(hostContext, fallbackLocale); + const timeZone = extractHostTimeZone(hostContext, fallbackTimeZone); + const displayMode = extractEffectiveHostDisplayMode(hostContext); + const availableDisplayModes = extractHostDisplayModes(hostContext); + const capabilities = extractHostDeviceCapabilities(hostContext); + + const handleDisplayModeChange = useCallback( + (nextDisplayMode: "inline" | "pip" | "fullscreen") => { + patchHostContext({ displayMode: nextDisplayMode }); + }, + [patchHostContext], + ); + + const toggleAvailableDisplayMode = useCallback( + (mode: "inline" | "pip" | "fullscreen") => { + const nextAvailableDisplayModes: HostDisplayMode[] = availableDisplayModes.includes(mode) + ? availableDisplayModes.filter((value) => value !== mode) + : [...availableDisplayModes, mode]; + const normalizedAvailableDisplayModes: HostDisplayMode[] = + nextAvailableDisplayModes.length > 0 ? nextAvailableDisplayModes : ["inline"]; + const nextDisplayMode = clampDisplayModeToAvailableModes( + displayMode, + normalizedAvailableDisplayModes, + ); + + patchHostContext({ + availableDisplayModes: normalizedAvailableDisplayModes, + displayMode: nextDisplayMode, + }); + }, + [availableDisplayModes, displayMode, patchHostContext], + ); + + const handleCapabilityToggle = useCallback( + (key: "hover" | "touch") => { + const nextCapabilities = { + hover: key === "hover" ? !capabilities.hover : capabilities.hover, + touch: key === "touch" ? !capabilities.touch : capabilities.touch, + }; + patchHostContext({ deviceCapabilities: nextCapabilities }); + }, + [capabilities, patchHostContext], + ); // Show ChatGPT Apps controls when: no protocol selected (default) or openai-apps const showChatGPTControls = @@ -394,7 +443,7 @@ export function DisplayContextHeader({ + + + +

Display Modes

+
+ + +
+
+
+ Current mode +
+
+ {(["inline", "pip", "fullscreen"] as const).map((mode) => ( + + ))} +
+
+
+
+ Host available modes +
+
+ {(["inline", "pip", "fullscreen"] as const).map((mode) => ( + + ))} +
+
+
+
+ + {/* CSP mode selector */} @@ -819,9 +937,7 @@ export function DisplayContextHeader({ - {themeMode === "dark" ? "Light mode" : "Dark mode"} + {theme === "dark" ? "Light mode" : "Dark mode"} )} diff --git a/mcpjam-inspector/client/src/components/ui-playground/AppBuilderTab.tsx b/mcpjam-inspector/client/src/components/ui-playground/AppBuilderTab.tsx index ce07b23d4..c9ef87358 100644 --- a/mcpjam-inspector/client/src/components/ui-playground/AppBuilderTab.tsx +++ b/mcpjam-inspector/client/src/components/ui-playground/AppBuilderTab.tsx @@ -20,10 +20,9 @@ import { PlaygroundLeft } from "./PlaygroundLeft"; import { PlaygroundMain } from "./PlaygroundMain"; import SaveRequestDialog from "../tools/SaveRequestDialog"; import { useUIPlaygroundStore } from "@/stores/ui-playground-store"; -import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; import { listTools } from "@/lib/apis/mcp-tools-api"; import { generateFormFieldsFromSchema } from "@/lib/tool-form"; -import type { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; import { detectEnvironment, detectPlatform } from "@/lib/PosthogUtils"; import { usePostHog } from "posthog-js/react"; @@ -44,7 +43,6 @@ export function AppBuilderTab({ serverName, }: AppBuilderTabProps) { const posthog = usePostHog(); - const themeMode = usePreferencesStore((s) => s.themeMode); // Compute server key for saved requests storage const serverKey = useServerKey(serverConfig); @@ -55,8 +53,6 @@ export function AppBuilderTab({ formFields, isExecuting, deviceType, - displayMode, - globals, isSidebarVisible, selectedProtocol, setTools, @@ -70,34 +66,11 @@ export function AppBuilderTab({ setExecutionError, setWidgetState, setDeviceType, - setDisplayMode, - updateGlobal, toggleSidebar, setSelectedProtocol, reset, } = useUIPlaygroundStore(); - // Sync theme from preferences to globals - useEffect(() => { - updateGlobal("theme", themeMode); - }, [themeMode, updateGlobal]); - - // Locale change handler - const handleLocaleChange = useCallback( - (locale: string) => { - updateGlobal("locale", locale); - }, - [updateGlobal], - ); - - // Timezone change handler (SEP-1865) - const handleTimeZoneChange = useCallback( - (timeZone: string) => { - updateGlobal("timeZone", timeZone); - }, - [updateGlobal], - ); - // Log when App Builder tab is viewed useEffect(() => { posthog.capture("app_builder_tab_viewed", { @@ -288,12 +261,6 @@ export function AppBuilderTab({ onWidgetStateChange={(_toolCallId, state) => setWidgetState(state)} deviceType={deviceType} onDeviceTypeChange={setDeviceType} - displayMode={displayMode} - onDisplayModeChange={setDisplayMode} - locale={globals.locale} - onLocaleChange={handleLocaleChange} - timeZone={globals.timeZone} - onTimeZoneChange={handleTimeZoneChange} /> diff --git a/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx b/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx index 2fabc01f7..333596327 100644 --- a/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx +++ b/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx @@ -43,7 +43,6 @@ import { type DeviceType, type DisplayMode, } from "@/stores/ui-playground-store"; -import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; import { CLAUDE_DESKTOP_CHAT_BACKGROUND } from "@/config/claude-desktop-host-context"; import { CHATGPT_CHAT_BACKGROUND } from "@/config/chatgpt-host-context"; import { @@ -62,6 +61,11 @@ import { ToolRenderOverride } from "@/components/chat-v2/thread/tool-render-over import { useConvexAuth } from "convex/react"; import { useWorkspaceServers } from "@/hooks/useViews"; import { buildOAuthTokensByServerId } from "@/lib/oauth/oauth-tokens"; +import { useClientConfigStore } from "@/stores/client-config-store"; +import { + extractEffectiveHostDisplayMode, + extractHostTheme, +} from "@/lib/client-config"; /** Custom device config - dimensions come from store */ const CUSTOM_DEVICE_BASE = { @@ -164,7 +168,7 @@ export function PlaygroundMain({ // These are kept for backward compatibility but are no longer used deviceType: _deviceType = "mobile", onDeviceTypeChange: _onDeviceTypeChange, - displayMode = "inline", + displayMode: displayModeProp = "inline", onDisplayModeChange, locale: _locale = "en-US", onLocaleChange: _onLocaleChange, @@ -202,6 +206,8 @@ export function PlaygroundMain({ // Device config from store (managed by DisplayContextHeader) const storeDeviceType = useUIPlaygroundStore((s) => s.deviceType); const customViewport = useUIPlaygroundStore((s) => s.customViewport); + const hostContext = useClientConfigStore((s) => s.draftConfig?.hostContext); + const patchHostContext = useClientConfigStore((s) => s.patchHostContext); // Device config for frame sizing const deviceConfig = useMemo(() => { @@ -301,12 +307,22 @@ export function PlaygroundMain({ // Host chat background: actual chat area colors from each host's UI // (separate from the 76 MCP spec widget design tokens) const hostStyle = useUIPlaygroundStore((s) => s.hostStyle); - const themeMode = usePreferencesStore((s) => s.themeMode); + const hostTheme = extractHostTheme(hostContext) ?? "dark"; const chatBg = hostStyle === "chatgpt" ? CHATGPT_CHAT_BACKGROUND : CLAUDE_DESKTOP_CHAT_BACKGROUND; - const hostBackgroundColor = chatBg[themeMode]; + const hostBackgroundColor = chatBg[hostTheme]; + const displayMode = + extractEffectiveHostDisplayMode(hostContext) ?? displayModeProp; + + const handleDisplayModeChange = useCallback( + (mode: DisplayMode) => { + patchHostContext({ displayMode: mode }); + onDisplayModeChange?.(mode); + }, + [patchHostContext, onDisplayModeChange], + ); // Check if thread is empty const isThreadEmpty = !messages.some( @@ -620,7 +636,7 @@ export function PlaygroundMain({ onWidgetStateChange={handleWidgetStateChange} onModelContextUpdate={handleModelContextUpdate} displayMode={displayMode} - onDisplayModeChange={onDisplayModeChange} + onDisplayModeChange={handleDisplayModeChange} onFullscreenChange={setIsWidgetFullscreen} selectedProtocolOverrideIfBothExists={ selectedProtocol ?? undefined diff --git a/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx b/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx index 0b9d21a3b..2245ef88e 100644 --- a/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx +++ b/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx @@ -9,14 +9,18 @@ import { RectangleHorizontal } from "lucide-react"; import { useUIPlaygroundStore, SAFE_AREA_PRESETS, - type SafeAreaInsets, type SafeAreaPreset, } from "@/stores/ui-playground-store"; import { cn } from "@/lib/utils"; +import { useClientConfigStore } from "@/stores/client-config-store"; +import { + extractHostSafeAreaInsets, + type HostSafeAreaInsets, +} from "@/lib/client-config"; /** Preset buttons for common device safe areas */ const PRESET_OPTIONS: { - preset: SafeAreaPreset; + preset: Exclude; label: string; shortLabel: string; }[] = [ @@ -31,7 +35,7 @@ const PRESET_OPTIONS: { ]; /** Safe Area Visualization - shows the insets visually */ -function SafeAreaVisualization({ insets }: { insets: SafeAreaInsets }) { +function SafeAreaVisualization({ insets }: { insets: HostSafeAreaInsets }) { const hasAnyInset = insets.top > 0 || insets.bottom > 0 || insets.left > 0 || insets.right > 0; @@ -145,12 +149,18 @@ function InsetInput({ export function SafeAreaEditor() { const safeAreaPreset = useUIPlaygroundStore((s) => s.safeAreaPreset); - const safeAreaInsets = useUIPlaygroundStore((s) => s.safeAreaInsets); const setSafeAreaPreset = useUIPlaygroundStore((s) => s.setSafeAreaPreset); - const setSafeAreaInsets = useUIPlaygroundStore((s) => s.setSafeAreaInsets); + const hostContext = useClientConfigStore((s) => s.draftConfig?.hostContext); + const patchHostContext = useClientConfigStore((s) => s.patchHostContext); + const safeAreaInsets = extractHostSafeAreaInsets(hostContext); - const handleInsetChange = (key: keyof SafeAreaInsets, value: number) => { - setSafeAreaInsets({ [key]: value }); + const handleInsetChange = (key: keyof HostSafeAreaInsets, value: number) => { + const nextInsets = { + ...safeAreaInsets, + [key]: value, + }; + setSafeAreaPreset("custom"); + patchHostContext({ safeAreaInsets: nextInsets }); }; const hasAnyInset = @@ -196,7 +206,12 @@ export function SafeAreaEditor() { safeAreaPreset === option.preset ? "secondary" : "ghost" } size="sm" - onClick={() => setSafeAreaPreset(option.preset)} + onClick={() => { + setSafeAreaPreset(option.preset); + patchHostContext({ + safeAreaInsets: SAFE_AREA_PRESETS[option.preset] ?? safeAreaInsets, + }); + }} className={cn( "flex-1 h-6 text-[10px] px-1", safeAreaPreset === option.preset && "ring-1 ring-ring", diff --git a/mcpjam-inspector/client/src/components/ui-playground/__tests__/AppBuilderTab.test.tsx b/mcpjam-inspector/client/src/components/ui-playground/__tests__/AppBuilderTab.test.tsx index b92e4fc47..3d011f111 100644 --- a/mcpjam-inspector/client/src/components/ui-playground/__tests__/AppBuilderTab.test.tsx +++ b/mcpjam-inspector/client/src/components/ui-playground/__tests__/AppBuilderTab.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { AppBuilderTab } from "../AppBuilderTab"; -import type { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; // Mock posthog vi.mock("posthog-js/react", () => ({ @@ -423,8 +423,8 @@ describe("AppBuilderTab", () => { }); }); - describe("theme sync", () => { - it("syncs theme from preferences to globals", async () => { + describe("display context source of truth", () => { + it("does not mirror theme into playground globals", async () => { const serverConfig = createServerConfig(); render( @@ -432,11 +432,15 @@ describe("AppBuilderTab", () => { ); await waitFor(() => { - expect(mockUIPlaygroundStore.updateGlobal).toHaveBeenCalledWith( - "theme", - "light", - ); + expect(mockListTools).toHaveBeenCalledWith({ + serverId: "test-server", + }); }); + + expect(mockUIPlaygroundStore.updateGlobal).not.toHaveBeenCalledWith( + "theme", + "light", + ); }); }); diff --git a/mcpjam-inspector/client/src/components/ui-playground/hooks/useServerKey.ts b/mcpjam-inspector/client/src/components/ui-playground/hooks/useServerKey.ts index 3b51d4052..f97373331 100644 --- a/mcpjam-inspector/client/src/components/ui-playground/hooks/useServerKey.ts +++ b/mcpjam-inspector/client/src/components/ui-playground/hooks/useServerKey.ts @@ -6,7 +6,7 @@ */ import { useMemo } from "react"; -import type { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; // Type guards for discriminated union interface StdioConfig { diff --git a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts index eba7a6185..1b1c06917 100644 --- a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts +++ b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts @@ -5,6 +5,7 @@ import { setHostedApiContext } from "@/lib/apis/web/context"; interface UseHostedApiContextOptions { workspaceId: string | null; serverIdsByName: Record; + clientCapabilities?: Record; getAccessToken: () => Promise; oauthTokensByServerId?: Record; guestOauthTokensByServerName?: Record; @@ -19,6 +20,7 @@ interface UseHostedApiContextOptions { export function useHostedApiContext({ workspaceId, serverIdsByName, + clientCapabilities, getAccessToken, oauthTokensByServerId, guestOauthTokensByServerName, @@ -46,6 +48,7 @@ export function useHostedApiContext({ setHostedApiContext({ workspaceId, serverIdsByName, + clientCapabilities, getAccessToken, oauthTokensByServerId, guestOauthTokensByServerName, @@ -62,6 +65,7 @@ export function useHostedApiContext({ enabled, workspaceId, serverIdsByName, + clientCapabilities, getAccessToken, oauthTokensByServerId, guestOauthTokensByServerName, diff --git a/mcpjam-inspector/client/src/hooks/use-app-state.ts b/mcpjam-inspector/client/src/hooks/use-app-state.ts index 5edf8afcf..611b37129 100644 --- a/mcpjam-inspector/client/src/hooks/use-app-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-app-state.ts @@ -261,6 +261,7 @@ export function useAppState({ handleSwitchWorkspace, handleCreateWorkspace: workspaceState.handleCreateWorkspace, handleUpdateWorkspace: workspaceState.handleUpdateWorkspace, + handleUpdateClientConfig: workspaceState.handleUpdateClientConfig, handleDeleteWorkspace: workspaceState.handleDeleteWorkspace, handleLeaveWorkspace, handleDuplicateWorkspace: workspaceState.handleDuplicateWorkspace, diff --git a/mcpjam-inspector/client/src/hooks/use-custom-providers.ts b/mcpjam-inspector/client/src/hooks/use-custom-providers.ts index 57eae42a8..41777f4f9 100644 --- a/mcpjam-inspector/client/src/hooks/use-custom-providers.ts +++ b/mcpjam-inspector/client/src/hooks/use-custom-providers.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from "react"; -import type { CustomProvider } from "@mcpjam/sdk"; +import type { CustomProvider } from "@mcpjam/sdk/browser"; const STORAGE_KEY = "mcp-inspector-custom-providers"; diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index 98ccf6ece..4068343ee 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, type Dispatch } from "react"; import { toast } from "sonner"; -import type { HttpServerConfig, MCPServerConfig } from "@mcpjam/sdk"; +import type { HttpServerConfig, MCPServerConfig } from "@mcpjam/sdk/browser"; import type { AppAction, AppState, @@ -33,6 +33,7 @@ import type { OAuthTestProfile } from "@/lib/oauth/profile"; import { authFetch } from "@/lib/session-token"; import { useUIPlaygroundStore } from "@/stores/ui-playground-store"; import { useServerMutations, type RemoteServer } from "./useWorkspaces"; +import { mergeWorkspaceClientCapabilities } from "@/lib/client-config"; /** * Saves OAuth-related configuration to localStorage for reconnection purposes. @@ -195,6 +196,26 @@ export function useServerState({ return activeWorkspace?.servers || {}; }, [activeWorkspace]); + const activeWorkspaceClientCapabilities = useMemo( + () => activeWorkspace?.clientConfig?.clientCapabilities, + [activeWorkspace?.clientConfig?.clientCapabilities], + ); + + const withWorkspaceClientCapabilities = useCallback( + (serverConfig: MCPServerConfig): MCPServerConfig => { + const mergedCapabilities = mergeWorkspaceClientCapabilities( + serverConfig.capabilities as Record | undefined, + activeWorkspaceClientCapabilities, + ); + + return { + ...serverConfig, + capabilities: mergedCapabilities, + }; + }, + [activeWorkspaceClientCapabilities], + ); + const validateForm = (formData: ServerFormData): string | null => { if (formData.type === "stdio") { if (!formData.command || formData.command.trim() === "") { @@ -431,7 +452,7 @@ export function useServerState({ try { const connectionResult = await testConnection( - result.serverConfig, + withWorkspaceClientCapabilities(result.serverConfig), serverName, ); if (connectionResult.success) { @@ -504,7 +525,13 @@ export function useServerState({ } } }, - [dispatch, failPendingOAuthConnection, logger, storeInitInfo], + [ + dispatch, + failPendingOAuthConnection, + logger, + storeInitInfo, + withWorkspaceClientCapabilities, + ], ); useEffect(() => { @@ -666,7 +693,7 @@ export function useServerState({ }, } satisfies HttpServerConfig; const connectionResult = await testConnection( - serverConfig, + withWorkspaceClientCapabilities(serverConfig), formData.name, ); if (isStaleOp(formData.name, token)) return; @@ -722,7 +749,7 @@ export function useServerState({ if (oauthResult.success) { if (oauthResult.serverConfig) { const connectionResult = await testConnection( - oauthResult.serverConfig, + withWorkspaceClientCapabilities(oauthResult.serverConfig), formData.name, ); if (isStaleOp(formData.name, token)) return; @@ -776,7 +803,8 @@ export function useServerState({ if (!hasPendingCallback) { clearOAuthData(formData.name); } - const result = await testConnection(mcpConfig, formData.name); + const effectiveConfig = withWorkspaceClientCapabilities(mcpConfig); + const result = await testConnection(effectiveConfig, formData.name); if (isStaleOp(formData.name, token)) return; if (result.success) { dispatch({ @@ -835,6 +863,7 @@ export function useServerState({ syncServerToConvex, logger, storeInitInfo, + withWorkspaceClientCapabilities, ], ); @@ -982,7 +1011,10 @@ export function useServerState({ const token = nextOpToken(serverName); try { - const result = await reconnectServer(serverName, serverConfig); + const result = await reconnectServer( + serverName, + withWorkspaceClientCapabilities(serverConfig), + ); if (isStaleOp(serverName, token)) { return { success: false, error: "Operation cancelled" }; } @@ -1016,7 +1048,7 @@ export function useServerState({ return { success: false, error: errorMessage }; } }, - [dispatch, storeInitInfo], + [dispatch, storeInitInfo, withWorkspaceClientCapabilities], ); const handleConnectWithTokensFromOAuthFlow = useCallback( @@ -1302,7 +1334,7 @@ export function useServerState({ } const result = await reconnectServer( serverName, - oauthResult.serverConfig!, + withWorkspaceClientCapabilities(oauthResult.serverConfig!), ); if (isStaleOp(serverName, token)) return; if (result.success) { @@ -1344,7 +1376,7 @@ export function useServerState({ } const result = await reconnectServer( serverName, - authResult.serverConfig, + withWorkspaceClientCapabilities(authResult.serverConfig), ); if (isStaleOp(serverName, token)) return; if (result.success) { @@ -1384,7 +1416,13 @@ export function useServerState({ }); } }, - [effectiveServers, storeInitInfo, logger, dispatch], + [ + effectiveServers, + storeInitInfo, + logger, + dispatch, + withWorkspaceClientCapabilities, + ], ); useEffect(() => { @@ -1534,7 +1572,7 @@ export function useServerState({ saveOAuthConfigToLocalStorage(formData); try { const result = await testConnection( - originalServer.config, + withWorkspaceClientCapabilities(originalServer.config), originalServerName, ); if (result.success) { diff --git a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index 4157d9a87..84d09b34c 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -17,6 +17,7 @@ import { deserializeServersFromConvex, serializeServersForSharing, } from "@/lib/workspace-serialization"; +import type { WorkspaceClientConfig } from "@/lib/client-config"; interface LoggerLike { info: (message: string, meta?: Record) => void; @@ -65,6 +66,7 @@ export function useWorkspaceState({ createWorkspace: convexCreateWorkspace, ensureDefaultWorkspace: convexEnsureDefaultWorkspace, updateWorkspace: convexUpdateWorkspace, + updateClientConfig: convexUpdateClientConfig, deleteWorkspace: convexDeleteWorkspace, } = useWorkspaceMutations(); @@ -159,6 +161,7 @@ export function useWorkspaceState({ name: rw.name, description: rw.description, icon: rw.icon, + clientConfig: rw.clientConfig, servers: deserializedServers, createdAt: new Date(rw.createdAt), updatedAt: new Date(rw.updatedAt), @@ -297,6 +300,7 @@ export function useWorkspaceState({ const workspaceId = await convexCreateWorkspace({ name: workspace.name, description: workspace.description, + clientConfig: workspace.clientConfig, servers: serializedServers, ...(activeOrganizationId ? { organizationId: activeOrganizationId } @@ -378,6 +382,7 @@ export function useWorkspaceState({ try { const workspaceId = await convexCreateWorkspace({ name, + clientConfig: undefined, servers: {}, ...(activeOrganizationId ? { organizationId: activeOrganizationId } @@ -450,6 +455,39 @@ export function useWorkspaceState({ [isAuthenticated, convexUpdateWorkspace, logger, dispatch], ); + const handleUpdateClientConfig = useCallback( + async ( + workspaceId: string, + clientConfig: WorkspaceClientConfig | undefined, + ): Promise => { + if (isAuthenticated) { + try { + await convexUpdateClientConfig({ + workspaceId, + clientConfig, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + logger.error("Failed to update workspace client config", { + error: errorMessage, + workspaceId, + }); + toast.error(errorMessage); + throw error instanceof Error ? error : new Error(errorMessage); + } + return; + } + + dispatch({ + type: "UPDATE_WORKSPACE", + workspaceId, + updates: { clientConfig }, + }); + }, + [isAuthenticated, convexUpdateClientConfig, logger, dispatch], + ); + const handleDeleteWorkspace = useCallback( async (workspaceId: string): Promise => { // If deleting the active workspace, switch to another first @@ -533,6 +571,7 @@ export function useWorkspaceState({ await convexCreateWorkspace({ name: newName, description: sourceWorkspace.description, + clientConfig: sourceWorkspace.clientConfig, servers: serializedServers, ...(activeOrganizationId ? { organizationId: activeOrganizationId } @@ -615,6 +654,7 @@ export function useWorkspaceState({ await convexCreateWorkspace({ name: workspaceData.name, description: workspaceData.description, + clientConfig: workspaceData.clientConfig, servers: serializedServers, ...(activeOrganizationId ? { organizationId: activeOrganizationId } @@ -652,6 +692,7 @@ export function useWorkspaceState({ effectiveActiveWorkspaceId, handleCreateWorkspace, handleUpdateWorkspace, + handleUpdateClientConfig, handleDeleteWorkspace, handleDuplicateWorkspace, handleSetDefaultWorkspace, diff --git a/mcpjam-inspector/client/src/hooks/useWorkspaces.ts b/mcpjam-inspector/client/src/hooks/useWorkspaces.ts index 18fcd8444..56bcc6ddf 100644 --- a/mcpjam-inspector/client/src/hooks/useWorkspaces.ts +++ b/mcpjam-inspector/client/src/hooks/useWorkspaces.ts @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useQuery, useMutation } from "convex/react"; import type { WorkspaceVisibility } from "@/state/app-types"; +import type { WorkspaceClientConfig } from "@/lib/client-config"; export type WorkspaceMembershipRole = "owner" | "admin" | "member" | "guest"; export type WorkspaceRole = "admin" | "editor"; @@ -14,6 +15,7 @@ export interface RemoteWorkspace { name: string; description?: string; icon?: string; + clientConfig?: WorkspaceClientConfig; servers: Record; canDeleteWorkspace?: boolean; organizationId?: string; @@ -175,6 +177,9 @@ export function useWorkspaceMutations() { "workspaces:ensureDefaultWorkspace" as any, ); const updateWorkspace = useMutation("workspaces:updateWorkspace" as any); + const updateClientConfig = useMutation( + "workspaces:updateClientConfig" as any, + ); const deleteWorkspace = useMutation("workspaces:deleteWorkspace" as any); const inviteWorkspaceMember = useMutation( "workspaces:inviteWorkspaceMember" as any, @@ -193,6 +198,7 @@ export function useWorkspaceMutations() { createWorkspace, ensureDefaultWorkspace, updateWorkspace, + updateClientConfig, deleteWorkspace, inviteWorkspaceMember, removeWorkspaceMember, diff --git a/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts b/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts index fbe76cafd..c9d5bc2fa 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; vi.mock("../config", () => ({ HOSTED_MODE: true, @@ -11,6 +12,9 @@ import { } from "../apis/web/context"; describe("hosted web context", () => { + const defaultClientCapabilities = + getDefaultClientCapabilities() as Record; + afterEach(() => { setHostedApiContext(null); localStorage.removeItem("mcp-tokens-myServer"); @@ -27,6 +31,7 @@ describe("hosted web context", () => { expect(buildHostedServerRequest("bench")).toEqual({ workspaceId: "ws_shared", serverId: "srv_bench", + clientCapabilities: defaultClientCapabilities, accessScope: "chat_v2", shareToken: "share_tok_123", }); @@ -34,6 +39,7 @@ describe("hosted web context", () => { expect(buildHostedServerBatchRequest(["bench"])).toEqual({ workspaceId: "ws_shared", serverIds: ["srv_bench"], + clientCapabilities: defaultClientCapabilities, accessScope: "chat_v2", shareToken: "share_tok_123", }); @@ -49,6 +55,7 @@ describe("hosted web context", () => { expect(buildHostedServerRequest("bench")).toEqual({ workspaceId: "ws_regular", serverId: "srv_bench", + clientCapabilities: defaultClientCapabilities, }); }); @@ -68,6 +75,7 @@ describe("hosted web context", () => { expect(buildHostedServerRequest("myServer")).toEqual({ serverUrl: "https://example.com/mcp", serverHeaders: { "X-Api-Key": "key123" }, + clientCapabilities: defaultClientCapabilities, }); }); @@ -88,6 +96,7 @@ describe("hosted web context", () => { expect(buildHostedServerRequest("myServer")).toEqual({ serverUrl: "https://example.com/mcp", serverHeaders: { "X-Api-Key": "key123" }, + clientCapabilities: defaultClientCapabilities, }); }); @@ -118,6 +127,7 @@ describe("hosted web context", () => { Authorization: "Bearer stale-access-token", "X-Api-Key": "key123", }, + clientCapabilities: defaultClientCapabilities, oauthAccessToken: "fresh-access-token", }); }); @@ -154,6 +164,7 @@ describe("hosted web context", () => { serverHeaders: { "X-Api-Key": "key123", }, + clientCapabilities: defaultClientCapabilities, oauthAccessToken: "storage-access-token", }); }); @@ -173,6 +184,27 @@ describe("hosted web context", () => { expect(buildHostedServerRequest("myServer")).toEqual({ serverUrl: "https://example.com/mcp", + clientCapabilities: defaultClientCapabilities, + }); + }); + + it("uses explicit client capabilities overrides when provided", () => { + const clientCapabilities = { + elicitation: {}, + experimental: { inspectorProfile: true }, + } as Record; + + setHostedApiContext({ + workspaceId: "ws_override", + serverIdsByName: { bench: "srv_bench" }, + clientCapabilities, + getAccessToken: async () => null, + }); + + expect(buildHostedServerRequest("bench")).toEqual({ + workspaceId: "ws_override", + serverId: "srv_bench", + clientCapabilities, }); }); diff --git a/mcpjam-inspector/client/src/lib/apis/mcp-prompts-api.ts b/mcpjam-inspector/client/src/lib/apis/mcp-prompts-api.ts index 5cf835c52..9f8a6b2d6 100644 --- a/mcpjam-inspector/client/src/lib/apis/mcp-prompts-api.ts +++ b/mcpjam-inspector/client/src/lib/apis/mcp-prompts-api.ts @@ -1,4 +1,4 @@ -import type { MCPPrompt } from "@mcpjam/sdk"; +import type { MCPPrompt } from "@mcpjam/sdk/browser"; import { authFetch } from "@/lib/session-token"; import { getHostedPrompt, diff --git a/mcpjam-inspector/client/src/lib/apis/mcp-resource-templates-api.ts b/mcpjam-inspector/client/src/lib/apis/mcp-resource-templates-api.ts index 59f565888..cf15d73b1 100644 --- a/mcpjam-inspector/client/src/lib/apis/mcp-resource-templates-api.ts +++ b/mcpjam-inspector/client/src/lib/apis/mcp-resource-templates-api.ts @@ -1,4 +1,4 @@ -import type { MCPResourceTemplate } from "@mcpjam/sdk"; +import type { MCPResourceTemplate } from "@mcpjam/sdk/browser"; import { authFetch } from "@/lib/session-token"; import { ensureLocalMode } from "@/lib/apis/mode-client"; diff --git a/mcpjam-inspector/client/src/lib/apis/mcp-tasks-api.ts b/mcpjam-inspector/client/src/lib/apis/mcp-tasks-api.ts index 831336fa5..302b77a2e 100644 --- a/mcpjam-inspector/client/src/lib/apis/mcp-tasks-api.ts +++ b/mcpjam-inspector/client/src/lib/apis/mcp-tasks-api.ts @@ -1,4 +1,4 @@ -import type { MCPTask, MCPListTasksResult } from "@mcpjam/sdk"; +import type { MCPListTasksResult, MCPTask } from "@mcpjam/sdk/browser"; import { authFetch } from "@/lib/session-token"; import { ensureLocalMode, runByMode } from "@/lib/apis/mode-client"; diff --git a/mcpjam-inspector/client/src/lib/apis/mcp-tools-api.ts b/mcpjam-inspector/client/src/lib/apis/mcp-tools-api.ts index 747eb961f..486605adf 100644 --- a/mcpjam-inspector/client/src/lib/apis/mcp-tools-api.ts +++ b/mcpjam-inspector/client/src/lib/apis/mcp-tools-api.ts @@ -4,7 +4,7 @@ import type { ElicitResult, ListToolsResult, } from "@modelcontextprotocol/sdk/types.js"; -import type { MCPTask, TaskOptions } from "@mcpjam/sdk"; +import type { MCPTask, TaskOptions } from "@mcpjam/sdk/browser"; import { authFetch } from "@/lib/session-token"; import { executeHostedTool, listHostedTools } from "@/lib/apis/web/tools-api"; import { isHostedMode, runByMode } from "@/lib/apis/mode-client"; diff --git a/mcpjam-inspector/client/src/lib/apis/web/__tests__/context.guest-fallback.test.ts b/mcpjam-inspector/client/src/lib/apis/web/__tests__/context.guest-fallback.test.ts index 9b7a49d5b..cd371f805 100644 --- a/mcpjam-inspector/client/src/lib/apis/web/__tests__/context.guest-fallback.test.ts +++ b/mcpjam-inspector/client/src/lib/apis/web/__tests__/context.guest-fallback.test.ts @@ -286,4 +286,25 @@ describe("isGuestMode and buildHostedServerRequest consistency", () => { /No guest server config found/, ); }); + + it("buildGuestServerRequest forwards explicit clientCapabilities overrides", () => { + expect( + buildGuestServerRequest( + { + url: "https://example.com/mcp", + }, + undefined, + { + elicitation: {}, + experimental: { inspectorProfile: true }, + }, + ), + ).toEqual({ + serverUrl: "https://example.com/mcp", + clientCapabilities: { + elicitation: {}, + experimental: { inspectorProfile: true }, + }, + }); + }); }); diff --git a/mcpjam-inspector/client/src/lib/apis/web/context.ts b/mcpjam-inspector/client/src/lib/apis/web/context.ts index b084f1451..e63ca91f6 100644 --- a/mcpjam-inspector/client/src/lib/apis/web/context.ts +++ b/mcpjam-inspector/client/src/lib/apis/web/context.ts @@ -1,11 +1,13 @@ import { HOSTED_MODE } from "@/lib/config"; import { getGuestBearerToken } from "@/lib/guest-session"; +import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; type GetAccessTokenFn = () => Promise; export interface HostedApiContext { workspaceId: string | null; serverIdsByName: Record; + clientCapabilities?: Record; getAccessToken?: GetAccessTokenFn; oauthTokensByServerId?: Record; guestOauthTokensByServerName?: Record; @@ -106,6 +108,7 @@ function shouldPreferGuestBearer(): boolean { export function buildGuestServerRequest( config: unknown, oauthAccessToken?: string, + clientCapabilities?: Record, ): Record { const httpConfig = config as { url?: string | URL; @@ -125,11 +128,19 @@ export function buildGuestServerRequest( ? { serverHeaders: headers } : {}), ...(oauthAccessToken ? { oauthAccessToken } : {}), + ...(clientCapabilities ? { clientCapabilities } : {}), }; } export function setHostedApiContext(next: HostedApiContext | null): void { - hostedApiContext = next ?? EMPTY_CONTEXT; + hostedApiContext = next + ? { + ...next, + clientCapabilities: + next.clientCapabilities ?? + (getDefaultClientCapabilities() as Record), + } + : EMPTY_CONTEXT; resetTokenCache(); } @@ -232,7 +243,11 @@ export function buildHostedServerRequest( readStoredGuestOAuthAccessToken(serverNameOrId) ?? hostedApiContext.guestOauthTokensByServerName?.[serverNameOrId]; - return buildGuestServerRequest(config, oauthToken); + return buildGuestServerRequest( + config, + oauthToken, + hostedApiContext.clientCapabilities, + ); } // Authenticated path: resolve via Convex server mappings @@ -245,6 +260,9 @@ export function buildHostedServerRequest( workspaceId: getHostedWorkspaceId(), serverId, ...(oauthToken ? { oauthAccessToken: oauthToken } : {}), + ...(hostedApiContext.clientCapabilities + ? { clientCapabilities: hostedApiContext.clientCapabilities } + : {}), ...(accessScope ? { accessScope } : {}), ...(shareToken ? { shareToken } : {}), ...(sandboxToken ? { sandboxToken } : {}), @@ -254,6 +272,7 @@ export function buildHostedServerRequest( export function buildHostedServerBatchRequest(serverNamesOrIds: string[]): { workspaceId: string; serverIds: string[]; + clientCapabilities?: Record; oauthTokens?: Record; accessScope?: HostedAccessScope; shareToken?: string; @@ -267,6 +286,9 @@ export function buildHostedServerBatchRequest(serverNamesOrIds: string[]): { return { workspaceId: getHostedWorkspaceId(), serverIds, + ...(hostedApiContext.clientCapabilities + ? { clientCapabilities: hostedApiContext.clientCapabilities } + : {}), ...(oauthTokens ? { oauthTokens } : {}), ...(accessScope ? { accessScope } : {}), ...(shareToken ? { shareToken } : {}), diff --git a/mcpjam-inspector/client/src/lib/apis/web/servers-api.ts b/mcpjam-inspector/client/src/lib/apis/web/servers-api.ts index c4c2309c4..18189acff 100644 --- a/mcpjam-inspector/client/src/lib/apis/web/servers-api.ts +++ b/mcpjam-inspector/client/src/lib/apis/web/servers-api.ts @@ -25,6 +25,7 @@ export async function checkHostedServerOAuthRequirement( export async function validateHostedServer( serverNameOrId: string, oauthAccessToken?: string, + clientCapabilities?: Record, ): Promise { const request = buildHostedServerRequest(serverNameOrId); // Prefer an explicit OAuth token (e.g. freshly obtained from the OAuth flow) @@ -32,6 +33,9 @@ export async function validateHostedServer( if (oauthAccessToken) { request.oauthAccessToken = oauthAccessToken; } + if (clientCapabilities) { + request.clientCapabilities = clientCapabilities; + } return webPost( "/api/web/servers/validate", request, diff --git a/mcpjam-inspector/client/src/lib/client-config.ts b/mcpjam-inspector/client/src/lib/client-config.ts new file mode 100644 index 000000000..cce5348dc --- /dev/null +++ b/mcpjam-inspector/client/src/lib/client-config.ts @@ -0,0 +1,253 @@ +import type { ClientCapabilityOptions } from "@mcpjam/sdk/browser"; +import { + getDefaultClientCapabilities, + normalizeClientCapabilities, + mergeClientCapabilities, +} from "@mcpjam/sdk/browser"; + +export type WorkspaceClientConfig = { + version: 1; + clientCapabilities: Record; + hostContext: Record; +}; + +export type HostDisplayMode = "inline" | "pip" | "fullscreen"; + +export type HostDeviceCapabilities = { + hover: boolean; + touch: boolean; +}; + +export type HostSafeAreaInsets = { + top: number; + right: number; + bottom: number; + left: number; +}; + +export const DEFAULT_HOST_DEVICE_CAPABILITIES: HostDeviceCapabilities = { + hover: true, + touch: false, +}; + +export const DEFAULT_HOST_SAFE_AREA_INSETS: HostSafeAreaInsets = { + top: 0, + right: 0, + bottom: 0, + left: 0, +}; + +export const DEFAULT_HOST_DISPLAY_MODES: HostDisplayMode[] = [ + "inline", + "pip", + "fullscreen", +]; + +export function buildDefaultHostContext(args: { + theme: "light" | "dark"; + displayMode: HostDisplayMode; + locale: string; + timeZone: string; + deviceCapabilities: HostDeviceCapabilities; + safeAreaInsets: HostSafeAreaInsets; +}): Record { + return { + theme: args.theme, + displayMode: args.displayMode, + availableDisplayModes: DEFAULT_HOST_DISPLAY_MODES, + locale: args.locale, + timeZone: args.timeZone, + deviceCapabilities: args.deviceCapabilities, + safeAreaInsets: args.safeAreaInsets, + }; +} + +export function buildDefaultWorkspaceClientConfig(args: { + theme: "light" | "dark"; + displayMode: HostDisplayMode; + locale: string; + timeZone: string; + deviceCapabilities: HostDeviceCapabilities; + safeAreaInsets: HostSafeAreaInsets; +}): WorkspaceClientConfig { + return { + version: 1, + clientCapabilities: getDefaultClientCapabilities() as Record< + string, + unknown + >, + hostContext: buildDefaultHostContext(args), + }; +} + +export function isWorkspaceClientConfig( + value: unknown, +): value is WorkspaceClientConfig { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record; + return ( + candidate.version === 1 && + isRecord(candidate.clientCapabilities) && + isRecord(candidate.hostContext) + ); +} + +export function sanitizeWorkspaceClientConfig( + value: unknown, + fallback: WorkspaceClientConfig, +): WorkspaceClientConfig { + return isWorkspaceClientConfig(value) ? value : fallback; +} + +export function mergeWorkspaceClientCapabilities( + serverCapabilities?: Record, + workspaceCapabilities?: Record, +): ClientCapabilityOptions { + return mergeClientCapabilities( + serverCapabilities as ClientCapabilityOptions | undefined, + workspaceCapabilities as ClientCapabilityOptions | undefined, + ); +} + +export function normalizeWorkspaceClientCapabilities( + capabilities?: Record, +): ClientCapabilityOptions { + return normalizeClientCapabilities( + capabilities as ClientCapabilityOptions | undefined, + ); +} + +export function workspaceClientCapabilitiesNeedReconnect(args: { + desiredCapabilities?: Record; + initializedCapabilities?: Record; +}): boolean { + return ( + stringifyJson( + normalizeWorkspaceClientCapabilities(args.desiredCapabilities), + ) !== + stringifyJson( + normalizeWorkspaceClientCapabilities(args.initializedCapabilities), + ) + ); +} + +export function extractHostDisplayModes( + hostContext?: Record, +): HostDisplayMode[] { + const modes = hostContext?.availableDisplayModes; + if (!Array.isArray(modes)) { + return DEFAULT_HOST_DISPLAY_MODES; + } + + const filtered = modes.filter(isHostDisplayMode); + return filtered.length > 0 ? filtered : ["inline"]; +} + +export function extractHostDisplayMode( + hostContext?: Record, +): HostDisplayMode | undefined { + const value = hostContext?.displayMode; + return isHostDisplayMode(value) ? value : undefined; +} + +export function extractEffectiveHostDisplayMode( + hostContext?: Record, +): HostDisplayMode { + return clampDisplayModeToAvailableModes( + extractHostDisplayMode(hostContext), + extractHostDisplayModes(hostContext), + ); +} + +export function extractHostTheme( + hostContext?: Record, +): "light" | "dark" | undefined { + const value = hostContext?.theme; + return value === "light" || value === "dark" ? value : undefined; +} + +export function extractHostLocale( + hostContext?: Record, + fallback = "en-US", +): string { + return typeof hostContext?.locale === "string" ? hostContext.locale : fallback; +} + +export function extractHostTimeZone( + hostContext?: Record, + fallback = "UTC", +): string { + return typeof hostContext?.timeZone === "string" + ? hostContext.timeZone + : fallback; +} + +export function extractHostDeviceCapabilities( + hostContext?: Record, + fallback: HostDeviceCapabilities = DEFAULT_HOST_DEVICE_CAPABILITIES, +): HostDeviceCapabilities { + const value = hostContext?.deviceCapabilities; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return fallback; + } + + const capabilities = value as { + hover?: boolean; + touch?: boolean; + }; + + return { + hover: capabilities.hover ?? fallback.hover, + touch: capabilities.touch ?? fallback.touch, + }; +} + +export function extractHostSafeAreaInsets( + hostContext?: Record, + fallback: HostSafeAreaInsets = DEFAULT_HOST_SAFE_AREA_INSETS, +): HostSafeAreaInsets { + const value = hostContext?.safeAreaInsets; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return fallback; + } + + const insets = value as { + top?: number; + right?: number; + bottom?: number; + left?: number; + }; + + return { + top: insets.top ?? fallback.top, + right: insets.right ?? fallback.right, + bottom: insets.bottom ?? fallback.bottom, + left: insets.left ?? fallback.left, + }; +} + +export function clampDisplayModeToAvailableModes( + displayMode: HostDisplayMode | undefined, + availableDisplayModes: HostDisplayMode[], +): HostDisplayMode { + if (displayMode && availableDisplayModes.includes(displayMode)) { + return displayMode; + } + + return availableDisplayModes[0] ?? "inline"; +} + +function isHostDisplayMode(value: unknown): value is HostDisplayMode { + return value === "inline" || value === "pip" || value === "fullscreen"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringifyJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} diff --git a/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts b/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts index c6ca5caf0..aef780c43 100644 --- a/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts +++ b/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts @@ -9,6 +9,7 @@ export const HOSTED_SIDEBAR_ALLOWED_TABS = [ "sandboxes", "app-builder", "views", + "client-config", "ci-evals", "tools", "resources", diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index af6f2d8c4..19212a5c1 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -7,7 +7,7 @@ import type { OAuthClientProvider, OAuthDiscoveryState, } from "@modelcontextprotocol/sdk/client/auth.js"; -import type { HttpServerConfig } from "@mcpjam/sdk"; +import type { HttpServerConfig } from "@mcpjam/sdk/browser"; import { generateRandomString } from "./state-machines/shared/helpers"; import { authFetch } from "@/lib/session-token"; import { HOSTED_MODE } from "@/lib/config"; diff --git a/mcpjam-inspector/client/src/state/__tests__/mcp-api.hosted.test.ts b/mcpjam-inspector/client/src/state/__tests__/mcp-api.hosted.test.ts index 809373207..ea15502f9 100644 --- a/mcpjam-inspector/client/src/state/__tests__/mcp-api.hosted.test.ts +++ b/mcpjam-inspector/client/src/state/__tests__/mcp-api.hosted.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; const validateHostedServerMock = vi.fn(); diff --git a/mcpjam-inspector/client/src/state/app-types.ts b/mcpjam-inspector/client/src/state/app-types.ts index 65138e94b..b23210bb8 100644 --- a/mcpjam-inspector/client/src/state/app-types.ts +++ b/mcpjam-inspector/client/src/state/app-types.ts @@ -1,6 +1,7 @@ -import { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; import { OauthTokens } from "@/shared/types.js"; import type { OAuthTestProfile } from "@/lib/oauth/profile"; +import type { WorkspaceClientConfig } from "@/lib/client-config"; export type ConnectionStatus = | "connected" @@ -50,6 +51,7 @@ export interface Workspace { name: string; description?: string; icon?: string; + clientConfig?: WorkspaceClientConfig; servers: Record; createdAt: Date; updatedAt: Date; diff --git a/mcpjam-inspector/client/src/state/mcp-api.ts b/mcpjam-inspector/client/src/state/mcp-api.ts index 34f13934a..d6e9de9b3 100644 --- a/mcpjam-inspector/client/src/state/mcp-api.ts +++ b/mcpjam-inspector/client/src/state/mcp-api.ts @@ -1,5 +1,4 @@ -import { MCPServerConfig } from "@mcpjam/sdk"; -import type { HttpServerConfig } from "@mcpjam/sdk"; +import type { HttpServerConfig, MCPServerConfig } from "@mcpjam/sdk/browser"; import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; import { authFetch } from "@/lib/session-token"; import { HOSTED_MODE } from "@/lib/config"; @@ -53,6 +52,8 @@ async function safeValidateHostedServer( return await validateHostedServer( serverId, extractOAuthToken(serverConfig), + (serverConfig.capabilities as Record | undefined) ?? + undefined, ); } catch (error) { return { diff --git a/mcpjam-inspector/client/src/state/server-helpers.ts b/mcpjam-inspector/client/src/state/server-helpers.ts index 4c9c987a9..0d02b729e 100644 --- a/mcpjam-inspector/client/src/state/server-helpers.ts +++ b/mcpjam-inspector/client/src/state/server-helpers.ts @@ -1,4 +1,4 @@ -import type { HttpServerConfig, MCPServerConfig } from "@mcpjam/sdk"; +import type { HttpServerConfig, MCPServerConfig } from "@mcpjam/sdk/browser"; import type { ServerFormData } from "@/shared/types.js"; export function toMCPConfig(formData: ServerFormData): MCPServerConfig { diff --git a/mcpjam-inspector/client/src/state/storage.ts b/mcpjam-inspector/client/src/state/storage.ts index d3f7d49fa..c79b0fe8b 100644 --- a/mcpjam-inspector/client/src/state/storage.ts +++ b/mcpjam-inspector/client/src/state/storage.ts @@ -4,6 +4,7 @@ import { ServerWithName, Workspace, } from "./app-types"; +import { isWorkspaceClientConfig } from "@/lib/client-config"; const STORAGE_KEY = "mcp-inspector-state"; const WORKSPACES_STORAGE_KEY = "mcp-inspector-workspaces"; @@ -48,6 +49,9 @@ export function loadAppState(): AppState { id, { ...workspace, + clientConfig: isWorkspaceClientConfig(workspace.clientConfig) + ? workspace.clientConfig + : undefined, servers: Object.fromEntries( Object.entries(workspace.servers || {}).map( ([name, server]) => [name, reviveServer(server)], diff --git a/mcpjam-inspector/client/src/stores/client-config-store.ts b/mcpjam-inspector/client/src/stores/client-config-store.ts new file mode 100644 index 000000000..4ab849c5b --- /dev/null +++ b/mcpjam-inspector/client/src/stores/client-config-store.ts @@ -0,0 +1,238 @@ +import { create } from "zustand"; +import type { WorkspaceClientConfig } from "@/lib/client-config"; + +type JsonSection = "clientCapabilities" | "hostContext"; + +interface ClientConfigStoreState { + activeWorkspaceId: string | null; + defaultConfig: WorkspaceClientConfig | null; + savedConfig: WorkspaceClientConfig | undefined; + draftConfig: WorkspaceClientConfig | null; + clientCapabilitiesText: string; + hostContextText: string; + clientCapabilitiesError: string | null; + hostContextError: string | null; + isSaving: boolean; + isDirty: boolean; + loadWorkspaceConfig: (input: { + workspaceId: string | null; + defaultConfig: WorkspaceClientConfig | null; + savedConfig?: WorkspaceClientConfig; + }) => void; + setSectionText: (section: JsonSection, text: string) => void; + patchHostContext: (patch: Record) => void; + resetSectionToDefault: (section: JsonSection) => void; + resetToBaseline: () => void; + markSaving: (isSaving: boolean) => void; + markSaved: (savedConfig: WorkspaceClientConfig | undefined) => void; +} + +function stringifyJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function createInitialState(): Omit< + ClientConfigStoreState, + | "loadWorkspaceConfig" + | "setSectionText" + | "patchHostContext" + | "resetSectionToDefault" + | "resetToBaseline" + | "markSaving" + | "markSaved" +> { + return { + activeWorkspaceId: null, + defaultConfig: null, + savedConfig: undefined, + draftConfig: null, + clientCapabilitiesText: "{}", + hostContextText: "{}", + clientCapabilitiesError: null, + hostContextError: null, + isSaving: false, + isDirty: false, + }; +} + +function computeBaselineConfig(state: Pick< + ClientConfigStoreState, + "defaultConfig" | "savedConfig" +>) { + return state.savedConfig ?? state.defaultConfig; +} + +function computeDirtyState(state: Pick< + ClientConfigStoreState, + "defaultConfig" | "savedConfig" | "draftConfig" +>) { + const baseline = computeBaselineConfig(state); + if (!baseline || !state.draftConfig) { + return false; + } + + return stringifyJson(state.draftConfig) !== stringifyJson(baseline); +} + +function resetFromConfig( + workspaceId: string | null, + defaultConfig: WorkspaceClientConfig | null, + savedConfig?: WorkspaceClientConfig, +) { + const baseline = savedConfig ?? defaultConfig; + return { + activeWorkspaceId: workspaceId, + defaultConfig, + savedConfig, + draftConfig: baseline, + clientCapabilitiesText: stringifyJson(baseline?.clientCapabilities ?? {}), + hostContextText: stringifyJson(baseline?.hostContext ?? {}), + clientCapabilitiesError: null, + hostContextError: null, + isDirty: false, + }; +} + +function parseRecordJson(text: string): Record { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Value must be a JSON object"); + } + return parsed as Record; +} + +function setSectionValue( + state: ClientConfigStoreState, + section: JsonSection, + nextValue: Record, +) { + if (!state.draftConfig) { + return state; + } + + const nextDraftConfig: WorkspaceClientConfig = { + ...state.draftConfig, + [section]: nextValue, + }; + + return { + draftConfig: nextDraftConfig, + isDirty: computeDirtyState({ + defaultConfig: state.defaultConfig, + savedConfig: state.savedConfig, + draftConfig: nextDraftConfig, + }), + }; +} + +export const useClientConfigStore = create( + (set, get) => ({ + ...createInitialState(), + + loadWorkspaceConfig: ({ workspaceId, defaultConfig, savedConfig }) => { + const state = get(); + const sameWorkspace = state.activeWorkspaceId === workspaceId; + const sameDefault = + stringifyJson(state.defaultConfig) === stringifyJson(defaultConfig); + const sameSaved = + stringifyJson(state.savedConfig) === stringifyJson(savedConfig); + + if (sameWorkspace && sameDefault && sameSaved) { + return; + } + + set(resetFromConfig(workspaceId, defaultConfig, savedConfig)); + }, + + setSectionText: (section, text) => { + set((state) => { + const textField = + section === "clientCapabilities" + ? "clientCapabilitiesText" + : "hostContextText"; + const errorField = + section === "clientCapabilities" + ? "clientCapabilitiesError" + : "hostContextError"; + + try { + const parsed = parseRecordJson(text); + return { + ...setSectionValue(state, section, parsed), + [textField]: text, + [errorField]: null, + }; + } catch (error) { + return { + [textField]: text, + [errorField]: + error instanceof Error ? error.message : "Invalid JSON", + }; + } + }); + }, + + patchHostContext: (patch) => { + set((state) => { + const currentHostContext = state.draftConfig?.hostContext ?? {}; + const nextHostContext = { + ...currentHostContext, + ...patch, + }; + const nextState = setSectionValue(state, "hostContext", nextHostContext); + return { + ...nextState, + hostContextText: stringifyJson(nextHostContext), + hostContextError: null, + }; + }); + }, + + resetSectionToDefault: (section) => { + set((state) => { + const defaultConfig = state.defaultConfig; + if (!defaultConfig) { + return {}; + } + + const nextValue = defaultConfig[section]; + const nextState = setSectionValue(state, section, nextValue); + return { + ...nextState, + ...(section === "clientCapabilities" + ? { + clientCapabilitiesText: stringifyJson(nextValue), + clientCapabilitiesError: null, + } + : { + hostContextText: stringifyJson(nextValue), + hostContextError: null, + }), + }; + }); + }, + + resetToBaseline: () => { + set((state) => + resetFromConfig( + state.activeWorkspaceId, + state.defaultConfig, + state.savedConfig, + ), + ); + }, + + markSaving: (isSaving) => set({ isSaving }), + + markSaved: (savedConfig) => + set((state) => ({ + savedConfig, + isSaving: false, + isDirty: computeDirtyState({ + defaultConfig: state.defaultConfig, + savedConfig, + draftConfig: state.draftConfig, + }), + })), + }), +); diff --git a/mcpjam-inspector/client/src/test/factories.ts b/mcpjam-inspector/client/src/test/factories.ts index ad08226ad..630054f88 100644 --- a/mcpjam-inspector/client/src/test/factories.ts +++ b/mcpjam-inspector/client/src/test/factories.ts @@ -7,7 +7,7 @@ import type { Workspace, ConnectionStatus, } from "@/state/app-types"; -import type { MCPServerConfig } from "@mcpjam/sdk"; +import type { MCPServerConfig } from "@mcpjam/sdk/browser"; // Counter for generating unique IDs let idCounter = 0; diff --git a/mcpjam-inspector/server/routes/web/auth.ts b/mcpjam-inspector/server/routes/web/auth.ts index 602745e37..d6f7c71d8 100644 --- a/mcpjam-inspector/server/routes/web/auth.ts +++ b/mcpjam-inspector/server/routes/web/auth.ts @@ -33,10 +33,13 @@ function refineHostedTokens(schema: z.ZodObject) { }); } +const clientCapabilitiesSchema = z.record(z.string(), z.unknown()); + export const workspaceServerSchema = refineHostedTokens( z.object({ workspaceId: z.string().min(1), serverId: z.string().min(1), + clientCapabilities: clientCapabilitiesSchema.optional(), oauthAccessToken: z.string().optional(), accessScope: z.enum(["workspace_member", "chat_v2"]).optional(), shareToken: z.string().min(1).optional(), @@ -71,6 +74,7 @@ export const promptsListMultiSchema = refineHostedTokens( z.object({ workspaceId: z.string().min(1), serverIds: z.array(z.string().min(1)).min(1), + clientCapabilities: clientCapabilitiesSchema.optional(), oauthTokens: z.record(z.string(), z.string()).optional(), accessScope: z.enum(["workspace_member", "chat_v2"]).optional(), shareToken: z.string().min(1).optional(), @@ -90,6 +94,7 @@ export const hostedChatSchema = refineHostedTokens( .object({ workspaceId: z.string().min(1), selectedServerIds: z.array(z.string().min(1)), + clientCapabilities: clientCapabilitiesSchema.optional(), chatSessionId: z.string().min(1).optional(), surface: z.enum(["preview", "share_link"]).optional(), oauthTokens: z.record(z.string(), z.string()).optional(), @@ -105,6 +110,7 @@ export const hostedChatSchema = refineHostedTokens( export const guestServerInputSchema = z.object({ serverUrl: z.string().min(1), serverHeaders: z.record(z.string(), z.string()).optional(), + clientCapabilities: clientCapabilitiesSchema.optional(), }); // ── Helpers ────────────────────────────────────────────────────────── @@ -215,6 +221,7 @@ function toHttpConfig( authResponse: ConvexAuthorizeResponse, timeoutMs: number, oauthAccessToken?: string, + clientCapabilities?: Record, ): HttpServerConfig { if (authResponse.serverConfig.transportType !== "http") { throw new WebRouteError( @@ -242,6 +249,7 @@ function toHttpConfig( return { url: authResponse.serverConfig.url, + capabilities: clientCapabilities, requestInit: { headers, }, @@ -261,6 +269,7 @@ export async function createAuthorizedManager( serverIds: string[], timeoutMs: number, oauthTokens?: Record, + clientCapabilities?: Record, options?: { accessScope?: "workspace_member" | "chat_v2"; shareToken?: string; @@ -296,7 +305,10 @@ export async function createAuthorizedManager( } } - return [serverId, toHttpConfig(auth, timeoutMs, oauthToken)] as const; + return [ + serverId, + toHttpConfig(auth, timeoutMs, oauthToken, clientCapabilities), + ] as const; }), ); @@ -464,6 +476,7 @@ export async function withEphemeralConnection( const httpConfig: HttpServerConfig = { url: guestInput.serverUrl, + capabilities: guestInput.clientCapabilities, requestInit: { headers, }, @@ -510,6 +523,8 @@ export async function withEphemeralConnection( serverIds, timeoutMs, oauthTokens, + (raw.clientCapabilities as Record | undefined) ?? + undefined, { accessScope, shareToken, diff --git a/mcpjam-inspector/server/routes/web/chat-v2.ts b/mcpjam-inspector/server/routes/web/chat-v2.ts index 921d89ba9..d28094c63 100644 --- a/mcpjam-inspector/server/routes/web/chat-v2.ts +++ b/mcpjam-inspector/server/routes/web/chat-v2.ts @@ -260,6 +260,7 @@ chatV2.post("/", async (c) => { selectedServerIds, WEB_STREAM_TIMEOUT_MS, hostedBody.oauthTokens, + hostedBody.clientCapabilities, { accessScope: "chat_v2", shareToken, diff --git a/mcpjam-inspector/server/routes/web/xray-payload.ts b/mcpjam-inspector/server/routes/web/xray-payload.ts index d53da0cb2..71f300694 100644 --- a/mcpjam-inspector/server/routes/web/xray-payload.ts +++ b/mcpjam-inspector/server/routes/web/xray-payload.ts @@ -47,6 +47,7 @@ xrayPayload.post("/", async (c) => { selectedServerIds, WEB_CALL_TIMEOUT_MS, body.oauthTokens, + body.clientCapabilities, ), async (manager) => { return buildXRayPayload( diff --git a/sdk/package.json b/sdk/package.json index 0a3f1d294..75d96d2a3 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -11,6 +11,11 @@ "import": "./dist/index.mjs", "require": "./dist/index.js" }, + "./browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.mjs", + "require": "./dist/browser.js" + }, "./skill-reference": { "types": "./dist/skill-reference.d.ts", "import": "./dist/skill-reference.mjs", diff --git a/sdk/src/browser.ts b/sdk/src/browser.ts new file mode 100644 index 000000000..ab10e92a6 --- /dev/null +++ b/sdk/src/browser.ts @@ -0,0 +1,44 @@ +/** + * Browser-safe SDK entrypoint. + * + * This subpath must stay free of Node-only runtime imports. + */ + +export { + MCP_UI_EXTENSION_ID, + MCP_UI_RESOURCE_MIME_TYPE, + getDefaultClientCapabilities, + normalizeClientCapabilities, + mergeClientCapabilities, +} from "./mcp-client-manager/capabilities.js"; + +export type { + BaseServerConfig, + HttpServerConfig, + StdioServerConfig, + MCPServerConfig, + MCPClientManagerConfig, + MCPConnectionStatus, + ServerSummary, + ClientCapabilityOptions, + ExecuteToolArguments, + TaskOptions, + ListToolsResult, + MCPPromptListResult, + MCPPrompt, + MCPGetPromptResult, + MCPResourceListResult, + MCPResource, + MCPReadResourceResult, + MCPResourceTemplateListResult, + MCPResourceTemplate, + MCPTask, + MCPTaskStatus, + MCPListTasksResult, +} from "./mcp-client-manager/types.js"; + +export type { + CompatibleProtocol, + CustomProvider, + LLMProvider, +} from "./types.js"; diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 93ce1b894..54079c72c 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -69,6 +69,13 @@ export { scrubMetaFromToolResult, scrubMetaAndStructuredContentFromToolResult, } from "./mcp-client-manager/index.js"; +export { + MCP_UI_EXTENSION_ID, + MCP_UI_RESOURCE_MIME_TYPE, + getDefaultClientCapabilities, + normalizeClientCapabilities, + mergeClientCapabilities, +} from "./mcp-client-manager/index.js"; // Error classes export { diff --git a/sdk/src/mcp-client-manager/MCPClientManager.ts b/sdk/src/mcp-client-manager/MCPClientManager.ts index 9b057cce2..459f8fed2 100644 --- a/sdk/src/mcp-client-manager/MCPClientManager.ts +++ b/sdk/src/mcp-client-manager/MCPClientManager.ts @@ -4,8 +4,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { - getDefaultEnvironment, StdioClientTransport, + getDefaultEnvironment, } from "@modelcontextprotocol/sdk/client/stdio.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -90,6 +90,7 @@ import { convertMCPToolsToVercelTools, type ToolSchemaOverrides, } from "./tool-converters.js"; +import { mergeClientCapabilities } from "./capabilities.js"; /** * Manages multiple MCP server connections with support for tools, resources, @@ -1254,20 +1255,7 @@ export class MCPClientManager { } private buildCapabilities(config: MCPServerConfig): ClientCapabilityOptions { - const capabilities: ClientCapabilityOptions = { - ...this.defaultCapabilities, - ...(config.capabilities ?? {}), - }; - if (!capabilities.elicitation) { - capabilities.elicitation = {}; - } - // Advertise MCP Apps UI support (ext-apps spec) - (capabilities as Record).extensions = { - "io.modelcontextprotocol/ui": { - mimeTypes: ["text/html;profile=mcp-app"], - }, - }; - return capabilities; + return mergeClientCapabilities(this.defaultCapabilities, config.capabilities); } private resolveRpcLogger(config: MCPServerConfig): RpcLogger | undefined { diff --git a/sdk/src/mcp-client-manager/capabilities.ts b/sdk/src/mcp-client-manager/capabilities.ts new file mode 100644 index 000000000..4866536b1 --- /dev/null +++ b/sdk/src/mcp-client-manager/capabilities.ts @@ -0,0 +1,38 @@ +import type { ClientCapabilityOptions } from "./types.js"; + +export const MCP_UI_EXTENSION_ID = "io.modelcontextprotocol/ui"; +export const MCP_UI_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +export function getDefaultClientCapabilities(): ClientCapabilityOptions { + return { + extensions: { + [MCP_UI_EXTENSION_ID]: { + mimeTypes: [MCP_UI_RESOURCE_MIME_TYPE], + }, + }, + } as ClientCapabilityOptions; +} + +export function normalizeClientCapabilities( + capabilities?: ClientCapabilityOptions, +): ClientCapabilityOptions { + const normalized: ClientCapabilityOptions = { + ...(capabilities ?? {}), + }; + + if (!normalized.elicitation) { + normalized.elicitation = {}; + } + + return normalized; +} + +export function mergeClientCapabilities( + base?: ClientCapabilityOptions, + overrides?: ClientCapabilityOptions, +): ClientCapabilityOptions { + return normalizeClientCapabilities({ + ...(base ?? {}), + ...(overrides ?? {}), + } as ClientCapabilityOptions); +} diff --git a/sdk/src/mcp-client-manager/index.ts b/sdk/src/mcp-client-manager/index.ts index 877b3e19f..d8e6e2bf1 100644 --- a/sdk/src/mcp-client-manager/index.ts +++ b/sdk/src/mcp-client-manager/index.ts @@ -82,6 +82,13 @@ export { // Utility functions (useful for testing and advanced use cases) export { buildRequestInit } from "./transport-utils.js"; export { isMethodUnavailableError, formatError } from "./error-utils.js"; +export { + MCP_UI_EXTENSION_ID, + MCP_UI_RESOURCE_MIME_TYPE, + getDefaultClientCapabilities, + normalizeClientCapabilities, + mergeClientCapabilities, +} from "./capabilities.js"; // Error classes export { diff --git a/sdk/tests/MCPClientManager.test.ts b/sdk/tests/MCPClientManager.test.ts index 45f6a0647..cf22bbf3f 100644 --- a/sdk/tests/MCPClientManager.test.ts +++ b/sdk/tests/MCPClientManager.test.ts @@ -1,4 +1,5 @@ import { MCPClientManager } from "../src/mcp-client-manager"; +import { getDefaultClientCapabilities } from "../src/mcp-client-manager/capabilities"; import { startMockHttpServer, startMockStreamableHttpServer, @@ -161,15 +162,24 @@ describe("MCPClientManager", () => { }, 10000); it("should support accessToken in config", async () => { - // The mock server doesn't validate tokens, but we test the config is accepted - await manager.connectToServer("http-server-auth", { - url: serverUrl, - accessToken: "test-bearer-token", - preferSSE: true, - }); + const isolated = await startMockHttpServer(); + const authManager = new MCPClientManager(); - expect(manager.getConnectionStatus("http-server-auth")).toBe("connected"); - }, 10000); + try { + await authManager.connectToServer("http-server-auth", { + url: isolated.url, + accessToken: "test-bearer-token", + preferSSE: true, + }); + + expect(authManager.getConnectionStatus("http-server-auth")).toBe( + "connected", + ); + } finally { + await authManager.disconnectAllServers(); + await isolated.stop(); + } + }, 15000); }); describe("HTTP server (streamable)", () => { @@ -277,10 +287,27 @@ describe("MCPClientManager", () => { expect(capabilities?.tools).toBeDefined(); }, 30000); - it("should advertise MCP Apps UI extension in client capabilities", async () => { + it("should not advertise MCP Apps UI extension by default", async () => { + await manager.connectToServer("extensions-test", { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-everything"], + }); + + const info = manager.getInitializationInfo("extensions-test"); + expect(info).toBeDefined(); + + const extensions = (info!.clientCapabilities as Record) + .extensions as Record; + expect(extensions).toBeUndefined(); + + await manager.disconnectServer("extensions-test"); + }, 30000); + + it("should advertise provided MCP Apps UI extension in client capabilities", async () => { await manager.connectToServer("extensions-test", { command: "npx", args: ["-y", "@modelcontextprotocol/server-everything"], + capabilities: getDefaultClientCapabilities(), }); const info = manager.getInitializationInfo("extensions-test"); @@ -288,7 +315,6 @@ describe("MCPClientManager", () => { const extensions = (info!.clientCapabilities as Record) .extensions as Record; - expect(extensions).toBeDefined(); expect(extensions["io.modelcontextprotocol/ui"]).toEqual({ mimeTypes: ["text/html;profile=mcp-app"], }); @@ -296,6 +322,32 @@ describe("MCPClientManager", () => { await manager.disconnectServer("extensions-test"); }, 30000); + it("should preserve custom capabilities without injecting the UI extension", async () => { + await manager.connectToServer("custom-caps-test", { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-everything"], + capabilities: { + experimental: { + inspectorProfile: {}, + }, + } as any, + }); + + const info = manager.getInitializationInfo("custom-caps-test"); + expect(info).toBeDefined(); + expect(info!.clientCapabilities).toMatchObject({ + experimental: { + inspectorProfile: {}, + }, + elicitation: {}, + }); + expect( + (info!.clientCapabilities as Record).extensions, + ).toBeUndefined(); + + await manager.disconnectServer("custom-caps-test"); + }, 30000); + it("should remove server", async () => { await manager.connectToServer("to-remove", { command: "npx", diff --git a/sdk/tests/browser-entry.test.ts b/sdk/tests/browser-entry.test.ts new file mode 100644 index 000000000..b4b1f6c21 --- /dev/null +++ b/sdk/tests/browser-entry.test.ts @@ -0,0 +1,18 @@ +import * as browser from "../src/browser"; + +describe("browser entrypoint", () => { + it("exports browser-safe capability helpers without MCPClientManager", () => { + expect(browser.MCP_UI_EXTENSION_ID).toBe("io.modelcontextprotocol/ui"); + expect(browser.MCP_UI_RESOURCE_MIME_TYPE).toBe("text/html;profile=mcp-app"); + expect(browser.getDefaultClientCapabilities()).toEqual({ + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: ["text/html;profile=mcp-app"], + }, + }, + }); + expect( + (browser as Record).MCPClientManager, + ).toBeUndefined(); + }); +}); diff --git a/sdk/tsup.config.ts b/sdk/tsup.config.ts index a7465a36f..28c44b641 100644 --- a/sdk/tsup.config.ts +++ b/sdk/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/skill-reference.ts"], + entry: ["src/index.ts", "src/browser.ts", "src/skill-reference.ts"], external: ["@sentry/node"], format: ["cjs", "esm"], dts: true, From 06e95b82f589d4a20ce563f28e8a0091c3816092 Mon Sep 17 00:00:00 2001 From: chelojimenez <58269507+chelojimenez@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:13:55 +0000 Subject: [PATCH 02/14] style: auto-fix prettier formatting --- mcpjam-inspector/client/src/App.tsx | 4 +- .../client/src/components/ServersTab.tsx | 10 +- .../components/__tests__/ServersTab.test.tsx | 6 +- .../chat-v2/thread/chatgpt-app-renderer.tsx | 4 +- .../thread/mcp-apps/mcp-apps-renderer.tsx | 105 +++++++++--------- .../parts/__tests__/display-modes.test.tsx | 4 +- .../client-config/ClientConfigTab.tsx | 14 +-- .../setting/CustomProviderConfigDialog.tsx | 5 +- .../shared/DisplayContextHeader.tsx | 75 +++++++------ .../ui-playground/SafeAreaEditor.tsx | 3 +- .../lib/__tests__/hosted-web-context.test.ts | 6 +- .../client/src/lib/client-config.ts | 4 +- .../client/src/stores/client-config-store.ts | 23 ++-- 13 files changed, 139 insertions(+), 124 deletions(-) diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index 319533464..3b40332ad 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -107,9 +107,7 @@ import { writeHostedOAuthResumeMarker, } from "./lib/hosted-oauth-resume"; import { handleOAuthCallback } from "./lib/oauth/mcp-oauth"; -import { - buildDefaultWorkspaceClientConfig, -} from "./lib/client-config"; +import { buildDefaultWorkspaceClientConfig } from "./lib/client-config"; import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; import type { BillingRolloutState, diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index b5241a049..ec07cc5f0 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -250,10 +250,8 @@ export function ServersTab({ server.connectionStatus === "connected" && workspaceClientCapabilitiesNeedReconnect({ desiredCapabilities, - initializedCapabilities: - server.initializationInfo?.clientCapabilities as - | Record - | undefined, + initializedCapabilities: server.initializationInfo + ?.clientCapabilities as Record | undefined, }), ]), ), @@ -521,7 +519,9 @@ export function ServersTab({
{ }); it("surfaces reconnect warnings when workspace client capabilities changed", () => { - const initializedCapabilities = - getDefaultClientCapabilities() as Record; + const initializedCapabilities = getDefaultClientCapabilities() as Record< + string, + unknown + >; const { rerender } = render( (null); const inlineWidthRef = useRef(undefined); const themeMode = usePreferencesStore((s) => s.themeMode); - const draftHostContext = useClientConfigStore((s) => s.draftConfig?.hostContext); + const draftHostContext = useClientConfigStore( + (s) => s.draftConfig?.hostContext, + ); // Get locale from playground store, fallback to navigator.language const playgroundLocale = useUIPlaygroundStore((s) => s.globals.locale); const locale = extractHostLocale( diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx index e45555d94..650ea0e40 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx @@ -179,7 +179,9 @@ export function MCPAppsRenderer({ const sandboxRef = useRef(null); const themeMode = usePreferencesStore((s) => s.themeMode); const sandboxHostStyle = useSandboxHostStyle(); - const draftHostContext = useClientConfigStore((s) => s.draftConfig?.hostContext); + const draftHostContext = useClientConfigStore( + (s) => s.draftConfig?.hostContext, + ); const baseHostContext = useMemo( () => draftHostContext && @@ -232,65 +234,59 @@ export function MCPAppsRenderer({ // Get device capabilities from playground store (SEP-1865) const playgroundCapabilities = useUIPlaygroundStore((s) => s.capabilities); - const deviceCapabilities = useMemo( - () => { - const configuredCapabilities = - draftHostContext?.deviceCapabilities && - typeof draftHostContext.deviceCapabilities === "object" && - !Array.isArray(draftHostContext.deviceCapabilities) - ? (draftHostContext.deviceCapabilities as { - hover?: boolean; - touch?: boolean; - }) - : undefined; - - if (configuredCapabilities) { - return { - hover: configuredCapabilities.hover ?? true, - touch: configuredCapabilities.touch ?? false, - }; - } + const deviceCapabilities = useMemo(() => { + const configuredCapabilities = + draftHostContext?.deviceCapabilities && + typeof draftHostContext.deviceCapabilities === "object" && + !Array.isArray(draftHostContext.deviceCapabilities) + ? (draftHostContext.deviceCapabilities as { + hover?: boolean; + touch?: boolean; + }) + : undefined; - return isPlaygroundActive - ? playgroundCapabilities - : { hover: true, touch: false }; - }, - [draftHostContext, isPlaygroundActive, playgroundCapabilities], - ); + if (configuredCapabilities) { + return { + hover: configuredCapabilities.hover ?? true, + touch: configuredCapabilities.touch ?? false, + }; + } + + return isPlaygroundActive + ? playgroundCapabilities + : { hover: true, touch: false }; + }, [draftHostContext, isPlaygroundActive, playgroundCapabilities]); // Get safe area insets from playground store (SEP-1865) const playgroundSafeAreaInsets = useUIPlaygroundStore( (s) => s.safeAreaInsets, ); - const safeAreaInsets = useMemo( - () => { - const configuredSafeAreaInsets = - draftHostContext?.safeAreaInsets && - typeof draftHostContext.safeAreaInsets === "object" && - !Array.isArray(draftHostContext.safeAreaInsets) - ? (draftHostContext.safeAreaInsets as { - top?: number; - right?: number; - bottom?: number; - left?: number; - }) - : undefined; - - if (configuredSafeAreaInsets) { - return { - top: configuredSafeAreaInsets.top ?? 0, - right: configuredSafeAreaInsets.right ?? 0, - bottom: configuredSafeAreaInsets.bottom ?? 0, - left: configuredSafeAreaInsets.left ?? 0, - }; - } + const safeAreaInsets = useMemo(() => { + const configuredSafeAreaInsets = + draftHostContext?.safeAreaInsets && + typeof draftHostContext.safeAreaInsets === "object" && + !Array.isArray(draftHostContext.safeAreaInsets) + ? (draftHostContext.safeAreaInsets as { + top?: number; + right?: number; + bottom?: number; + left?: number; + }) + : undefined; + + if (configuredSafeAreaInsets) { + return { + top: configuredSafeAreaInsets.top ?? 0, + right: configuredSafeAreaInsets.right ?? 0, + bottom: configuredSafeAreaInsets.bottom ?? 0, + left: configuredSafeAreaInsets.left ?? 0, + }; + } - return isPlaygroundActive - ? playgroundSafeAreaInsets - : { top: 0, right: 0, bottom: 0, left: 0 }; - }, - [draftHostContext, isPlaygroundActive, playgroundSafeAreaInsets], - ); + return isPlaygroundActive + ? playgroundSafeAreaInsets + : { top: 0, right: 0, bottom: 0, left: 0 }; + }, [draftHostContext, isPlaygroundActive, playgroundSafeAreaInsets]); // Get device type from playground store for platform derivation (SEP-1865) const playgroundDeviceType = useUIPlaygroundStore((s) => s.deviceType); @@ -299,7 +295,8 @@ export function MCPAppsRenderer({ const isControlled = displayModeProp !== undefined; const [internalDisplayMode, setInternalDisplayMode] = useState( clampDisplayModeToAvailableModes( - configuredDisplayMode ?? (isPlaygroundActive ? playgroundDisplayMode : "inline"), + configuredDisplayMode ?? + (isPlaygroundActive ? playgroundDisplayMode : "inline"), configuredAvailableDisplayModes, ), ); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx index 18e115182..b0934b9f0 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx @@ -182,7 +182,9 @@ describe("ToolPart display mode controls", () => { renderWithDisplayModes(["inline", "pip", "fullscreen"]); - const disabledButtons = screen.getAllByRole("button").filter((b) => b.disabled); + const disabledButtons = screen + .getAllByRole("button") + .filter((b) => b.disabled); expect(disabledButtons).toHaveLength(2); expect(screen.getByLabelText("Inline")).not.toBeDisabled(); expect(screen.getByLabelText("PiP")).toBeDisabled(); diff --git a/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx b/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx index f6ae9a0d6..95165e988 100644 --- a/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx +++ b/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx @@ -63,10 +63,8 @@ export function ClientConfigTab({ return workspaceClientCapabilitiesNeedReconnect({ desiredCapabilities, - initializedCapabilities: - server.initializationInfo?.clientCapabilities as - | Record - | undefined, + initializedCapabilities: server.initializationInfo + ?.clientCapabilities as Record | undefined, }); }); }, [desiredCapabilities, workspace]); @@ -88,7 +86,9 @@ export function ClientConfigTab({ } catch (error) { markSaving(false); toast.error( - error instanceof Error ? error.message : "Failed to save client profile.", + error instanceof Error + ? error.message + : "Failed to save client profile.", ); } }; @@ -132,9 +132,7 @@ export function ClientConfigTab({
-
- Needs reconnect -
+
Needs reconnect

Saved client capabilities differ from the last initialize payload for:{" "} diff --git a/mcpjam-inspector/client/src/components/setting/CustomProviderConfigDialog.tsx b/mcpjam-inspector/client/src/components/setting/CustomProviderConfigDialog.tsx index 82fcf60a0..0e1552349 100644 --- a/mcpjam-inspector/client/src/components/setting/CustomProviderConfigDialog.tsx +++ b/mcpjam-inspector/client/src/components/setting/CustomProviderConfigDialog.tsx @@ -1,8 +1,5 @@ import { useState, useEffect } from "react"; -import type { - CompatibleProtocol, - CustomProvider, -} from "@mcpjam/sdk/browser"; +import type { CompatibleProtocol, CustomProvider } from "@mcpjam/sdk/browser"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { diff --git a/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx b/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx index 8834af36a..6680092f9 100644 --- a/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx +++ b/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx @@ -244,11 +244,14 @@ export function DisplayContextHeader({ const toggleAvailableDisplayMode = useCallback( (mode: "inline" | "pip" | "fullscreen") => { - const nextAvailableDisplayModes: HostDisplayMode[] = availableDisplayModes.includes(mode) - ? availableDisplayModes.filter((value) => value !== mode) - : [...availableDisplayModes, mode]; + const nextAvailableDisplayModes: HostDisplayMode[] = + availableDisplayModes.includes(mode) + ? availableDisplayModes.filter((value) => value !== mode) + : [...availableDisplayModes, mode]; const normalizedAvailableDisplayModes: HostDisplayMode[] = - nextAvailableDisplayModes.length > 0 ? nextAvailableDisplayModes : ["inline"]; + nextAvailableDisplayModes.length > 0 + ? nextAvailableDisplayModes + : ["inline"]; const nextDisplayMode = clampDisplayModeToAvailableModes( displayMode, normalizedAvailableDisplayModes, @@ -832,19 +835,23 @@ export function DisplayContextHeader({ Current mode

- {(["inline", "pip", "fullscreen"] as const).map((mode) => ( - - ))} + {(["inline", "pip", "fullscreen"] as const).map( + (mode) => ( + + ), + )}
@@ -852,22 +859,24 @@ export function DisplayContextHeader({ Host available modes
- {(["inline", "pip", "fullscreen"] as const).map((mode) => ( - - ))} + {(["inline", "pip", "fullscreen"] as const).map( + (mode) => ( + + ), + )}
diff --git a/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx b/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx index 2245ef88e..10f239b99 100644 --- a/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx +++ b/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx @@ -209,7 +209,8 @@ export function SafeAreaEditor() { onClick={() => { setSafeAreaPreset(option.preset); patchHostContext({ - safeAreaInsets: SAFE_AREA_PRESETS[option.preset] ?? safeAreaInsets, + safeAreaInsets: + SAFE_AREA_PRESETS[option.preset] ?? safeAreaInsets, }); }} className={cn( diff --git a/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts b/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts index c9d5bc2fa..b01675406 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts @@ -12,8 +12,10 @@ import { } from "../apis/web/context"; describe("hosted web context", () => { - const defaultClientCapabilities = - getDefaultClientCapabilities() as Record; + const defaultClientCapabilities = getDefaultClientCapabilities() as Record< + string, + unknown + >; afterEach(() => { setHostedApiContext(null); diff --git a/mcpjam-inspector/client/src/lib/client-config.ts b/mcpjam-inspector/client/src/lib/client-config.ts index cce5348dc..7315b140b 100644 --- a/mcpjam-inspector/client/src/lib/client-config.ts +++ b/mcpjam-inspector/client/src/lib/client-config.ts @@ -173,7 +173,9 @@ export function extractHostLocale( hostContext?: Record, fallback = "en-US", ): string { - return typeof hostContext?.locale === "string" ? hostContext.locale : fallback; + return typeof hostContext?.locale === "string" + ? hostContext.locale + : fallback; } export function extractHostTimeZone( diff --git a/mcpjam-inspector/client/src/stores/client-config-store.ts b/mcpjam-inspector/client/src/stores/client-config-store.ts index 4ab849c5b..a7a255497 100644 --- a/mcpjam-inspector/client/src/stores/client-config-store.ts +++ b/mcpjam-inspector/client/src/stores/client-config-store.ts @@ -55,17 +55,18 @@ function createInitialState(): Omit< }; } -function computeBaselineConfig(state: Pick< - ClientConfigStoreState, - "defaultConfig" | "savedConfig" ->) { +function computeBaselineConfig( + state: Pick, +) { return state.savedConfig ?? state.defaultConfig; } -function computeDirtyState(state: Pick< - ClientConfigStoreState, - "defaultConfig" | "savedConfig" | "draftConfig" ->) { +function computeDirtyState( + state: Pick< + ClientConfigStoreState, + "defaultConfig" | "savedConfig" | "draftConfig" + >, +) { const baseline = computeBaselineConfig(state); if (!baseline || !state.draftConfig) { return false; @@ -179,7 +180,11 @@ export const useClientConfigStore = create( ...currentHostContext, ...patch, }; - const nextState = setSectionValue(state, "hostContext", nextHostContext); + const nextState = setSectionValue( + state, + "hostContext", + nextHostContext, + ); return { ...nextState, hostContextText: stringifyJson(nextHostContext), From cee8dacd2f84aed9964b6d746c7e6a15556ef800 Mon Sep 17 00:00:00 2001 From: marcelo Date: Mon, 23 Mar 2026 22:30:45 -0700 Subject: [PATCH 03/14] hide behind flag --- mcpjam-inspector/client/src/App.tsx | 7 +++++++ .../src/components/chat-v2/thread/parts/tool-part.tsx | 6 ++++-- mcpjam-inspector/client/src/components/mcp-sidebar.tsx | 5 ++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index 319533464..4348bff99 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -146,6 +146,7 @@ export default function App() { "billing-entitlements-ui", ); const learningEnabled = useFeatureFlagEnabled("mcpjam-learning"); + const clientConfigEnabled = useFeatureFlagEnabled("client-config-enabled"); const { getAccessToken, signIn, @@ -678,9 +679,15 @@ export default function App() { (learningEnabled !== true || !isAuthenticated) ) { applyNavigation("servers", { updateHash: true }); + } else if ( + activeTab === "client-config" && + (clientConfigEnabled !== true || !isAuthenticated) + ) { + applyNavigation("servers", { updateHash: true }); } }, [ ciEvalsEnabled, + clientConfigEnabled, activeTabBillingFeature, activeTabBillingLocked, learningEnabled, diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx index 934bf4bd2..e7919b0fe 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx @@ -157,8 +157,10 @@ export function ToolPart({ const widgetDebugInfo = useWidgetDebugStore((s) => toolCallId ? s.widgets.get(toolCallId) : undefined, ); - const hostAvailableDisplayModes = useClientConfigStore((s) => - extractHostDisplayModes(s.draftConfig?.hostContext), + const hostContext = useClientConfigStore((s) => s.draftConfig?.hostContext); + const hostAvailableDisplayModes = useMemo( + () => extractHostDisplayModes(hostContext), + [hostContext], ); const hasWidgetDebug = !!widgetDebugInfo; const hasWidgetDebugUI = !hideDiagnosticsUI && hasWidgetDebug; diff --git a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx index a131d6012..38fcd488f 100644 --- a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx +++ b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx @@ -150,6 +150,7 @@ const navigationSections: NavSection[] = [ title: "Client Config", url: "#client-config", icon: Settings, + featureFlag: "client-config-enabled", }, { title: "Generate Evals", @@ -286,6 +287,7 @@ export function MCPSidebar({ const ciEvalsEnabled = useFeatureFlagEnabled("ci-evals-enabled"); const learningFlagEnabled = useFeatureFlagEnabled("mcpjam-learning"); const sandboxesEnabled = useFeatureFlagEnabled("sandboxes-enabled"); + const clientConfigEnabled = useFeatureFlagEnabled("client-config-enabled"); const { isAuthenticated } = useConvexAuth(); const learningEnabled = !!learningFlagEnabled && isAuthenticated; const themeMode = usePreferencesStore((s) => s.themeMode); @@ -382,8 +384,9 @@ export function MCPSidebar({ "ci-evals-enabled": !!ciEvalsEnabled && isAuthenticated, "mcpjam-learning": !!learningEnabled, "sandboxes-enabled": !!sandboxesEnabled && isAuthenticated, + "client-config-enabled": !!clientConfigEnabled && isAuthenticated, }), - [ciEvalsEnabled, learningEnabled, sandboxesEnabled, isAuthenticated], + [ciEvalsEnabled, learningEnabled, sandboxesEnabled, clientConfigEnabled, isAuthenticated], ); const visibleNavigationSections = filterByBillingEntitlements( filterByFeatureFlags( From 1fb1e4d4bc898281a5e39baa038b369544f361ac Mon Sep 17 00:00:00 2001 From: chelojimenez <58269507+chelojimenez@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:32:00 +0000 Subject: [PATCH 04/14] style: auto-fix prettier formatting --- mcpjam-inspector/client/src/components/mcp-sidebar.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx index 38fcd488f..c065b44e4 100644 --- a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx +++ b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx @@ -386,7 +386,13 @@ export function MCPSidebar({ "sandboxes-enabled": !!sandboxesEnabled && isAuthenticated, "client-config-enabled": !!clientConfigEnabled && isAuthenticated, }), - [ciEvalsEnabled, learningEnabled, sandboxesEnabled, clientConfigEnabled, isAuthenticated], + [ + ciEvalsEnabled, + learningEnabled, + sandboxesEnabled, + clientConfigEnabled, + isAuthenticated, + ], ); const visibleNavigationSections = filterByBillingEntitlements( filterByFeatureFlags( From 49cd93e3d7b6726394bce72baa3532d4bbb97f39 Mon Sep 17 00:00:00 2001 From: marcelo Date: Mon, 23 Mar 2026 22:36:46 -0700 Subject: [PATCH 05/14] nits --- .../client/src/components/ServersTab.tsx | 14 ++-- .../components/__tests__/ServersTab.test.tsx | 47 +++++++++++++ .../chat-v2/thread/chatgpt-app-renderer.tsx | 16 ++++- .../parts/__tests__/display-modes.test.tsx | 26 ++----- .../client/src/test/mocks/index.ts | 2 + .../client/src/test/mocks/stores.ts | 68 +++++++++++++++++++ 6 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 mcpjam-inspector/client/src/test/mocks/index.ts diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index ec07cc5f0..b30dbaac4 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -29,7 +29,10 @@ import { useConvexAuth } from "convex/react"; import { Workspace } from "@/state/app-types"; import { useWorkspaceServers as useRemoteWorkspaceServers } from "@/hooks/useWorkspaces"; import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; -import { workspaceClientCapabilitiesNeedReconnect } from "@/lib/client-config"; +import { + mergeWorkspaceClientCapabilities, + workspaceClientCapabilitiesNeedReconnect, +} from "@/lib/client-config"; import { DndContext, closestCenter, @@ -237,7 +240,7 @@ export function ServersTab({ }; const activeServer = activeId ? workspaceServers[activeId] : null; - const desiredCapabilities = + const workspaceDesiredCapabilities = (workspaces[activeWorkspaceId]?.clientConfig?.clientCapabilities as | Record | undefined) ?? @@ -249,13 +252,16 @@ export function ServersTab({ serverName, server.connectionStatus === "connected" && workspaceClientCapabilitiesNeedReconnect({ - desiredCapabilities, + desiredCapabilities: mergeWorkspaceClientCapabilities( + server.config.capabilities as Record | undefined, + workspaceDesiredCapabilities, + ), initializedCapabilities: server.initializationInfo ?.clientCapabilities as Record | undefined, }), ]), ), - [desiredCapabilities, workspaceServers], + [workspaceDesiredCapabilities, workspaceServers], ); const detailModalLiveServer = detailModalState.serverName diff --git a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx index 2ed75a3b5..9b70782ec 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx @@ -5,6 +5,7 @@ import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; import type { ServerWithName, ServerUpdateResult } from "@/hooks/use-app-state"; import type { Workspace } from "@/state/app-types"; import type { ServerFormData } from "@/shared/types.js"; +import { mergeWorkspaceClientCapabilities } from "@/lib/client-config"; import { captureServerDetailModalOAuthResume, writeOpenServerDetailModalState, @@ -449,4 +450,50 @@ describe("ServersTab shared detail modal", () => { expect(screen.getByText("Needs reconnect")).toBeInTheDocument(); }); + + it("does not surface reconnect warnings when server capability overrides already match initialize payload", () => { + const serverCapabilities = { + experimental: { + serverOverride: true, + }, + }; + const initializedCapabilities = mergeWorkspaceClientCapabilities( + serverCapabilities, + getDefaultClientCapabilities() as Record, + ); + + render( + , + ); + + expect(screen.queryByText("Needs reconnect")).not.toBeInTheDocument(); + }); }); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx index 5d684e0fb..55a2e6f41 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx @@ -45,6 +45,7 @@ import { extractHostLocale, extractHostSafeAreaInsets, extractHostTheme, + extractHostTimeZone, } from "@/lib/client-config"; type ToolState = @@ -628,13 +629,20 @@ export function ChatGPTAppRenderer({ const draftHostContext = useClientConfigStore( (s) => s.draftConfig?.hostContext, ); - // Get locale from playground store, fallback to navigator.language + // Get locale and time zone from playground store, fallback to browser settings const playgroundLocale = useUIPlaygroundStore((s) => s.globals.locale); + const playgroundTimeZone = useUIPlaygroundStore((s) => s.globals.timeZone); const locale = extractHostLocale( draftHostContext, playgroundLocale || navigator.language || "en-US", ); const resolvedTheme = extractHostTheme(draftHostContext) ?? themeMode; + const hostTimeZone = extractHostTimeZone( + draftHostContext, + playgroundTimeZone || + Intl.DateTimeFormat().resolvedOptions().timeZone || + "UTC", + ); const { resolvedToolCallId, @@ -906,6 +914,7 @@ export function ChatGPTAppRenderer({ displayMode: effectiveDisplayMode, maxHeight: maxHeight ?? undefined, locale, + timeZone: hostTimeZone, safeArea: { insets: safeAreaInsets }, userAgent: { device: { type: deviceType }, @@ -921,6 +930,7 @@ export function ChatGPTAppRenderer({ effectiveDisplayMode, maxHeight, locale, + hostTimeZone, deviceType, capabilities, safeAreaInsets, @@ -1397,6 +1407,7 @@ export function ChatGPTAppRenderer({ displayMode: "inline", maxHeight: null, locale, + timeZone: hostTimeZone, safeArea: { insets: safeAreaInsets }, userAgent: { device: { type: deviceType }, @@ -1415,6 +1426,7 @@ export function ChatGPTAppRenderer({ }, [ resolvedTheme, locale, + hostTimeZone, deviceType, capabilities, safeAreaInsets, @@ -1453,6 +1465,7 @@ export function ChatGPTAppRenderer({ theme: resolvedTheme, displayMode: effectiveDisplayMode, locale, + timeZone: hostTimeZone, safeArea: { insets: safeAreaInsets }, userAgent: { device: { type: deviceType }, @@ -1474,6 +1487,7 @@ export function ChatGPTAppRenderer({ maxHeight, effectiveDisplayMode, locale, + hostTimeZone, deviceType, capabilities, safeAreaInsets, diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx index b0934b9f0..20b129570 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ToolPart } from "../tool-part"; +import { useClientConfigStore } from "@/stores/client-config-store"; +import { storePresets } from "@/test/mocks"; // Mock lucide-react icons vi.mock("lucide-react", () => { @@ -44,20 +46,6 @@ vi.mock("@/stores/widget-debug-store", () => ({ }), })); -const mockClientConfigStoreState = { - draftConfig: undefined as - | { - hostContext?: { - availableDisplayModes?: ("inline" | "pip" | "fullscreen")[]; - }; - } - | undefined, -}; - -vi.mock("@/stores/client-config-store", () => ({ - useClientConfigStore: (selector: any) => selector(mockClientConfigStoreState), -})); - // Mock thread-helpers vi.mock("../../thread-helpers", () => ({ getToolNameFromType: () => "test-tool", @@ -104,7 +92,7 @@ describe("ToolPart display mode controls", () => { beforeEach(() => { vi.clearAllMocks(); onDisplayModeChange = vi.fn(); - mockClientConfigStoreState.draftConfig = undefined; + useClientConfigStore.setState(storePresets.clientConfig()); }); const renderWithDisplayModes = ( @@ -174,11 +162,9 @@ describe("ToolPart display mode controls", () => { }); it("disables modes that the host does not advertise even when the app supports them", () => { - mockClientConfigStoreState.draftConfig = { - hostContext: { - availableDisplayModes: ["inline"], - }, - }; + useClientConfigStore.setState( + storePresets.clientConfigWithHostDisplayModes(["inline"]), + ); renderWithDisplayModes(["inline", "pip", "fullscreen"]); diff --git a/mcpjam-inspector/client/src/test/mocks/index.ts b/mcpjam-inspector/client/src/test/mocks/index.ts new file mode 100644 index 000000000..e1a1560c7 --- /dev/null +++ b/mcpjam-inspector/client/src/test/mocks/index.ts @@ -0,0 +1,2 @@ +export * from "./mcp-api"; +export * from "./stores"; diff --git a/mcpjam-inspector/client/src/test/mocks/stores.ts b/mcpjam-inspector/client/src/test/mocks/stores.ts index da707ae0c..17d82f829 100644 --- a/mcpjam-inspector/client/src/test/mocks/stores.ts +++ b/mcpjam-inspector/client/src/test/mocks/stores.ts @@ -4,6 +4,10 @@ */ import { vi } from "vitest"; import type { AppState, ServerWithName, Workspace } from "@/state/app-types"; +import type { + HostDisplayMode, + WorkspaceClientConfig, +} from "@/lib/client-config"; import { createServer, createWorkspace } from "../factories"; /** @@ -152,6 +156,54 @@ export function createMockWorkspaceMutations(overrides = {}) { }; } +export type MockClientConfigStoreState = { + activeWorkspaceId: string | null; + defaultConfig: WorkspaceClientConfig | null; + savedConfig: WorkspaceClientConfig | undefined; + draftConfig: WorkspaceClientConfig | null; + clientCapabilitiesText: string; + hostContextText: string; + clientCapabilitiesError: string | null; + hostContextError: string | null; + isSaving: boolean; + isDirty: boolean; +}; + +function stringifyJson(value: unknown) { + return JSON.stringify(value, null, 2); +} + +export function createMockWorkspaceClientConfig( + overrides: Partial = {}, +): WorkspaceClientConfig { + return { + version: 1, + clientCapabilities: overrides.clientCapabilities ?? {}, + hostContext: overrides.hostContext ?? {}, + }; +} + +export function createMockClientConfigStoreState( + overrides: Partial = {}, +): MockClientConfigStoreState { + const draftConfig = + overrides.draftConfig === undefined ? null : overrides.draftConfig; + + return { + activeWorkspaceId: null, + defaultConfig: null, + savedConfig: undefined, + draftConfig, + clientCapabilitiesText: stringifyJson(draftConfig?.clientCapabilities ?? {}), + hostContextText: stringifyJson(draftConfig?.hostContext ?? {}), + clientCapabilitiesError: null, + hostContextError: null, + isSaving: false, + isDirty: false, + ...overrides, + }; +} + /** * Presets for common testing scenarios */ @@ -227,4 +279,20 @@ export const storePresets = { createMockUseAppState({ isCloudSyncActive: true, }), + + /** Empty client config store state */ + clientConfig: (overrides: Partial = {}) => + createMockClientConfigStoreState(overrides), + + /** Client config with specific host-advertised display modes */ + clientConfigWithHostDisplayModes: ( + availableDisplayModes: HostDisplayMode[], + overrides: Partial = {}, + ) => + createMockClientConfigStoreState({ + draftConfig: createMockWorkspaceClientConfig({ + hostContext: { availableDisplayModes }, + }), + ...overrides, + }), }; From f1a2f087d63822fa1d9b0f599f0a5aaa0fbb11a1 Mon Sep 17 00:00:00 2001 From: marcelo Date: Mon, 23 Mar 2026 22:38:36 -0700 Subject: [PATCH 06/14] nit --- .../client/src/state/__tests__/mcp-api.hosted.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/mcpjam-inspector/client/src/state/__tests__/mcp-api.hosted.test.ts b/mcpjam-inspector/client/src/state/__tests__/mcp-api.hosted.test.ts index ea15502f9..f18ca5f07 100644 --- a/mcpjam-inspector/client/src/state/__tests__/mcp-api.hosted.test.ts +++ b/mcpjam-inspector/client/src/state/__tests__/mcp-api.hosted.test.ts @@ -83,6 +83,7 @@ describe("mcp-api hosted-mode reconnect hardening", () => { expect(validateHostedServerMock).toHaveBeenCalledWith( "server-4", "access-token", + undefined, ); expect(result).toEqual({ success: true, status: "ok" }); }); From 1758858da6f9a62d58310c1d15a18c4e3d27ad5d Mon Sep 17 00:00:00 2001 From: chelojimenez <58269507+chelojimenez@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:39:45 +0000 Subject: [PATCH 07/14] style: auto-fix prettier formatting --- mcpjam-inspector/client/src/components/ServersTab.tsx | 4 +++- mcpjam-inspector/client/src/test/mocks/stores.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index b30dbaac4..d4f59e458 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -253,7 +253,9 @@ export function ServersTab({ server.connectionStatus === "connected" && workspaceClientCapabilitiesNeedReconnect({ desiredCapabilities: mergeWorkspaceClientCapabilities( - server.config.capabilities as Record | undefined, + server.config.capabilities as + | Record + | undefined, workspaceDesiredCapabilities, ), initializedCapabilities: server.initializationInfo diff --git a/mcpjam-inspector/client/src/test/mocks/stores.ts b/mcpjam-inspector/client/src/test/mocks/stores.ts index 17d82f829..20531306f 100644 --- a/mcpjam-inspector/client/src/test/mocks/stores.ts +++ b/mcpjam-inspector/client/src/test/mocks/stores.ts @@ -194,7 +194,9 @@ export function createMockClientConfigStoreState( defaultConfig: null, savedConfig: undefined, draftConfig, - clientCapabilitiesText: stringifyJson(draftConfig?.clientCapabilities ?? {}), + clientCapabilitiesText: stringifyJson( + draftConfig?.clientCapabilities ?? {}, + ), hostContextText: stringifyJson(draftConfig?.hostContext ?? {}), clientCapabilitiesError: null, hostContextError: null, From b18c9f34a2da40e42bac4cb20965ecdba4ab46ff Mon Sep 17 00:00:00 2001 From: marcelo Date: Mon, 23 Mar 2026 23:33:52 -0700 Subject: [PATCH 08/14] nits --- mcpjam-inspector/client/src/App.tsx | 20 ++- .../client/src/components/ServersTab.tsx | 21 ++- .../components/__tests__/ServersTab.test.tsx | 4 +- .../client-config/ClientConfigTab.tsx | 34 ++-- .../__tests__/ClientConfigTab.test.tsx | 95 ++++++++++++ .../shared/DisplayContextHeader.tsx | 3 +- .../hooks/__tests__/use-server-state.test.tsx | 42 +++++ .../__tests__/use-workspace-state.test.tsx | 146 +++++++++++++++++- .../hooks/hosted/use-hosted-api-context.ts | 4 + .../client/src/hooks/use-server-state.ts | 115 +++++++++++--- .../client/src/hooks/use-workspace-state.ts | 124 ++++++++++++++- .../lib/__tests__/hosted-web-context.test.ts | 36 +++++ .../client/src/lib/apis/web/context.ts | 12 ++ .../client/src/lib/client-config.ts | 61 +++++++- mcpjam-inspector/client/src/state/mcp-api.ts | 3 +- .../client/src/stores/client-config-store.ts | 88 ++++++++++- sdk/src/mcp-client-manager/capabilities.ts | 65 +++++++- sdk/tests/browser-entry.test.ts | 35 +++++ 18 files changed, 821 insertions(+), 87 deletions(-) create mode 100644 mcpjam-inspector/client/src/components/client-config/__tests__/ClientConfigTab.test.tsx diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index 3eb1495b7..3bd082a50 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -107,8 +107,10 @@ import { writeHostedOAuthResumeMarker, } from "./lib/hosted-oauth-resume"; import { handleOAuthCallback } from "./lib/oauth/mcp-oauth"; -import { buildDefaultWorkspaceClientConfig } from "./lib/client-config"; -import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; +import { + buildDefaultWorkspaceClientConfig, + getEffectiveWorkspaceClientCapabilities, +} from "./lib/client-config"; import type { BillingRolloutState, OrganizationEntitlements, @@ -435,11 +437,14 @@ export default function App() { // Get the Convex workspace ID from the active workspace const activeWorkspace = workspaces[activeWorkspaceId]; - const hostedClientCapabilities = - (activeWorkspace?.clientConfig?.clientCapabilities as - | Record - | undefined) ?? - (getDefaultClientCapabilities() as Record); + const isClientConfigSyncPending = useClientConfigStore( + (state) => + state.isAwaitingRemoteEcho && + state.pendingWorkspaceId === activeWorkspaceId, + ); + const hostedClientCapabilities = getEffectiveWorkspaceClientCapabilities( + activeWorkspace?.clientConfig, + ) as Record; const convexWorkspaceId = activeWorkspace?.sharedWorkspaceId ?? null; const rawBillingOrganizationId = activeOrganizationId ?? activeWorkspace?.organizationId ?? null; @@ -545,6 +550,7 @@ export default function App() { workspaceId: convexWorkspaceId, serverIdsByName: hostedServerIdsByName, clientCapabilities: hostedClientCapabilities, + clientConfigSyncPending: isClientConfigSyncPending, getAccessToken, oauthTokensByServerId, guestOauthTokensByServerName, diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index b30dbaac4..5a22934d9 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -28,9 +28,8 @@ import { Skeleton } from "./ui/skeleton"; import { useConvexAuth } from "convex/react"; import { Workspace } from "@/state/app-types"; import { useWorkspaceServers as useRemoteWorkspaceServers } from "@/hooks/useWorkspaces"; -import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; import { - mergeWorkspaceClientCapabilities, + getEffectiveServerClientCapabilities, workspaceClientCapabilitiesNeedReconnect, } from "@/lib/client-config"; import { @@ -240,11 +239,6 @@ export function ServersTab({ }; const activeServer = activeId ? workspaceServers[activeId] : null; - const workspaceDesiredCapabilities = - (workspaces[activeWorkspaceId]?.clientConfig?.clientCapabilities as - | Record - | undefined) ?? - (getDefaultClientCapabilities() as Record); const reconnectWarningByServerName = useMemo( () => Object.fromEntries( @@ -252,16 +246,19 @@ export function ServersTab({ serverName, server.connectionStatus === "connected" && workspaceClientCapabilitiesNeedReconnect({ - desiredCapabilities: mergeWorkspaceClientCapabilities( - server.config.capabilities as Record | undefined, - workspaceDesiredCapabilities, - ), + desiredCapabilities: getEffectiveServerClientCapabilities({ + workspaceClientConfig: workspaces[activeWorkspaceId] + ?.clientConfig, + serverCapabilities: server.config.capabilities as + | Record + | undefined, + }), initializedCapabilities: server.initializationInfo ?.clientCapabilities as Record | undefined, }), ]), ), - [workspaceDesiredCapabilities, workspaceServers], + [activeWorkspaceId, workspaceServers, workspaces], ); const detailModalLiveServer = detailModalState.serverName diff --git a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx index 9b70782ec..2e0862e6c 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx @@ -454,12 +454,12 @@ describe("ServersTab shared detail modal", () => { it("does not surface reconnect warnings when server capability overrides already match initialize payload", () => { const serverCapabilities = { experimental: { - serverOverride: true, + serverOverride: { enabled: true }, }, }; const initializedCapabilities = mergeWorkspaceClientCapabilities( - serverCapabilities, getDefaultClientCapabilities() as Record, + serverCapabilities, ); render( diff --git a/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx b/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx index 95165e988..811e26e3d 100644 --- a/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx +++ b/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx @@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { JsonEditor } from "@/components/ui/json-editor"; import type { Workspace } from "@/state/app-types"; import { + getEffectiveServerClientCapabilities, workspaceClientCapabilitiesNeedReconnect, type WorkspaceClientConfig, } from "@/lib/client-config"; @@ -26,7 +27,6 @@ export function ClientConfigTab({ workspace, onSaveClientConfig, }: ClientConfigTabProps) { - const defaultConfig = useClientConfigStore((s) => s.defaultConfig); const draftConfig = useClientConfigStore((s) => s.draftConfig); const clientCapabilitiesText = useClientConfigStore( (s) => s.clientCapabilitiesText, @@ -43,13 +43,7 @@ export function ClientConfigTab({ (s) => s.resetSectionToDefault, ); const resetToBaseline = useClientConfigStore((s) => s.resetToBaseline); - const markSaving = useClientConfigStore((s) => s.markSaving); - const markSaved = useClientConfigStore((s) => s.markSaved); - - const desiredCapabilities = - workspace?.clientConfig?.clientCapabilities ?? - defaultConfig?.clientCapabilities ?? - {}; + const failSave = useClientConfigStore((s) => s.failSave); const reconnectServers = useMemo(() => { if (!workspace) { @@ -62,12 +56,17 @@ export function ClientConfigTab({ } return workspaceClientCapabilitiesNeedReconnect({ - desiredCapabilities, + desiredCapabilities: getEffectiveServerClientCapabilities({ + workspaceClientConfig: workspace.clientConfig, + serverCapabilities: server.config.capabilities as + | Record + | undefined, + }), initializedCapabilities: server.initializationInfo ?.clientCapabilities as Record | undefined, }); }); - }, [desiredCapabilities, workspace]); + }, [workspace]); const handleSave = async () => { if (!draftConfig) { @@ -78,18 +77,11 @@ export function ClientConfigTab({ return; } - markSaving(true); try { await onSaveClientConfig(activeWorkspaceId, draftConfig); - markSaved(draftConfig); toast.success("Workspace client profile saved."); - } catch (error) { - markSaving(false); - toast.error( - error instanceof Error - ? error.message - : "Failed to save client profile.", - ); + } catch { + failSave(); } }; @@ -122,7 +114,7 @@ export function ClientConfigTab({
@@ -173,6 +165,7 @@ export function ClientConfigTab({ setSectionText("clientCapabilities", value) } mode="edit" + readOnly={isSaving} showModeToggle={false} className="h-full border" height="100%" @@ -208,6 +201,7 @@ export function ClientConfigTab({ rawContent={hostContextText} onRawChange={(value) => setSectionText("hostContext", value)} mode="edit" + readOnly={isSaving} showModeToggle={false} className="h-full border" height="100%" diff --git a/mcpjam-inspector/client/src/components/client-config/__tests__/ClientConfigTab.test.tsx b/mcpjam-inspector/client/src/components/client-config/__tests__/ClientConfigTab.test.tsx new file mode 100644 index 000000000..d2a72a4bb --- /dev/null +++ b/mcpjam-inspector/client/src/components/client-config/__tests__/ClientConfigTab.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; +import { ClientConfigTab } from "../ClientConfigTab"; +import { + mergeWorkspaceClientCapabilities, + type WorkspaceClientConfig, +} from "@/lib/client-config"; +import { useClientConfigStore } from "@/stores/client-config-store"; + +vi.mock("@/components/ui/json-editor", () => ({ + JsonEditor: () =>
, +})); + +function resetClientConfigStore(defaultConfig: WorkspaceClientConfig) { + useClientConfigStore.setState({ + activeWorkspaceId: "workspace-1", + defaultConfig, + savedConfig: undefined, + draftConfig: defaultConfig, + clientCapabilitiesText: JSON.stringify( + defaultConfig.clientCapabilities, + null, + 2, + ), + hostContextText: JSON.stringify(defaultConfig.hostContext, null, 2), + clientCapabilitiesError: null, + hostContextError: null, + isSaving: false, + isDirty: false, + pendingWorkspaceId: null, + pendingSavedConfig: undefined, + isAwaitingRemoteEcho: false, + }); +} + +describe("ClientConfigTab reconnect warnings", () => { + beforeEach(() => { + const defaultConfig: WorkspaceClientConfig = { + version: 1, + clientCapabilities: getDefaultClientCapabilities() as Record< + string, + unknown + >, + hostContext: {}, + }; + + resetClientConfigStore(defaultConfig); + }); + + it("does not warn when server capability overrides already match the last initialize payload", () => { + const serverCapabilities = { + experimental: { + serverOverride: { enabled: true }, + }, + }; + const initializedCapabilities = mergeWorkspaceClientCapabilities( + getDefaultClientCapabilities() as Record, + serverCapabilities, + ); + + render( + , + ); + + expect(screen.queryByText("Needs reconnect")).not.toBeInTheDocument(); + }); +}); diff --git a/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx b/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx index 6680092f9..e162ed5e6 100644 --- a/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx +++ b/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx @@ -4,7 +4,8 @@ * Reusable component for display context controls (device, locale, timezone, CSP, capabilities, safe area). * Extracted from PlaygroundMain to be shared between App Builder and Views pages. * - * Reads/writes to useUIPlaygroundStore for state management. + * Reads/writes UI playground state via useUIPlaygroundStore and derives theme, locale, timezone, + * display modes, device capabilities, and safe-area defaults from useClientConfigStore hostContext. */ import { useState, useMemo, useCallback, useEffect, useRef } from "react"; diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx index e88223f92..bdaf1d475 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx @@ -1,6 +1,8 @@ import { renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AppState, AppAction } from "@/state/app-types"; +import { CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE } from "@/lib/client-config"; +import { useClientConfigStore } from "@/stores/client-config-store"; import { useServerState } from "../use-server-state"; const { toastError, toastSuccess, handleOAuthCallbackMock } = vi.hoisted( @@ -138,6 +140,21 @@ describe("useServerState OAuth callback failures", () => { vi.clearAllMocks(); localStorage.clear(); window.history.replaceState({}, "", "/"); + useClientConfigStore.setState({ + activeWorkspaceId: null, + defaultConfig: null, + savedConfig: undefined, + draftConfig: null, + clientCapabilitiesText: "{}", + hostContextText: "{}", + clientCapabilitiesError: null, + hostContextError: null, + isSaving: false, + isDirty: false, + pendingWorkspaceId: null, + pendingSavedConfig: undefined, + isAwaitingRemoteEcho: false, + }); }); it("marks the pending server as failed when authorization is denied", async () => { @@ -193,4 +210,29 @@ describe("useServerState OAuth callback failures", () => { ); expect(localStorage.getItem("mcp-oauth-pending")).toBeNull(); }); + + it("blocks connect while workspace client config sync is pending", async () => { + useClientConfigStore.setState({ + pendingWorkspaceId: "default", + isAwaitingRemoteEcho: true, + }); + + const dispatch = vi.fn(); + const { result } = renderUseServerState(dispatch); + + await result.current.handleConnect({ + name: "new-server", + type: "http", + url: "https://example.com/mcp", + }); + + expect(toastError).toHaveBeenCalledWith( + CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE, + ); + expect( + dispatch.mock.calls.some( + ([action]) => action.type === "CONNECT_REQUEST", + ), + ).toBe(false); + }); }); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx index 6f08fff4a..7b800d5db 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx @@ -1,11 +1,14 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AppAction, AppState, Workspace } from "@/state/app-types"; import { useWorkspaceState } from "../use-workspace-state"; +import { useClientConfigStore } from "@/stores/client-config-store"; +import type { WorkspaceClientConfig } from "@/lib/client-config"; const { createWorkspaceMock, ensureDefaultWorkspaceMock, + updateClientConfigMock, updateWorkspaceMock, deleteWorkspaceMock, workspaceQueryState, @@ -13,6 +16,7 @@ const { } = vi.hoisted(() => ({ createWorkspaceMock: vi.fn(), ensureDefaultWorkspaceMock: vi.fn(), + updateClientConfigMock: vi.fn(), updateWorkspaceMock: vi.fn(), deleteWorkspaceMock: vi.fn(), workspaceQueryState: { @@ -37,6 +41,7 @@ vi.mock("../useWorkspaces", () => ({ createWorkspace: createWorkspaceMock, ensureDefaultWorkspace: ensureDefaultWorkspaceMock, updateWorkspace: updateWorkspaceMock, + updateClientConfig: updateClientConfigMock, deleteWorkspace: deleteWorkspaceMock, }), useWorkspaceServers: () => ({ @@ -127,16 +132,37 @@ function renderUseWorkspaceState({ } describe("useWorkspaceState automatic workspace creation", () => { + afterEach(() => { + vi.useRealTimers(); + }); + beforeEach(() => { vi.clearAllMocks(); + vi.useRealTimers(); localStorage.clear(); createWorkspaceMock.mockResolvedValue("remote-workspace-id"); ensureDefaultWorkspaceMock.mockResolvedValue("default-workspace-id"); + updateClientConfigMock.mockResolvedValue(undefined); updateWorkspaceMock.mockResolvedValue("remote-workspace-id"); deleteWorkspaceMock.mockResolvedValue(undefined); workspaceQueryState.allWorkspaces = []; workspaceQueryState.workspaces = []; workspaceQueryState.isLoading = false; + useClientConfigStore.setState({ + activeWorkspaceId: null, + defaultConfig: null, + savedConfig: undefined, + draftConfig: null, + clientCapabilitiesText: "{}", + hostContextText: "{}", + clientCapabilitiesError: null, + hostContextError: null, + isSaving: false, + isDirty: false, + pendingWorkspaceId: null, + pendingSavedConfig: undefined, + isAwaitingRemoteEcho: false, + }); }); it("ensures one initial workspace per empty organization and dedupes rerenders", async () => { @@ -258,4 +284,120 @@ describe("useWorkspaceState automatic workspace creation", () => { expect(ensureDefaultWorkspaceMock).toHaveBeenCalledTimes(1); }); }); + + it("keeps authenticated client-config saves pending until the remote echo arrives", async () => { + const savedConfig: WorkspaceClientConfig = { + version: 1, + clientCapabilities: { + experimental: { + inspectorProfile: true, + }, + }, + hostContext: {}, + }; + + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + clientConfig: undefined, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + const appState = createAppState({ + default: createSyntheticDefaultWorkspace(), + }); + const { result, rerender } = renderUseWorkspaceState({ appState }); + + let resolved = false; + const savePromise = result.current + .handleUpdateClientConfig("remote-1", savedConfig) + .then(() => { + resolved = true; + }); + + await waitFor(() => { + expect(updateClientConfigMock).toHaveBeenCalledWith({ + workspaceId: "remote-1", + clientConfig: savedConfig, + }); + }); + + expect(useClientConfigStore.getState().isAwaitingRemoteEcho).toBe(true); + expect(resolved).toBe(false); + + workspaceQueryState.allWorkspaces = [ + { + ...workspaceQueryState.allWorkspaces[0], + clientConfig: savedConfig, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + rerender({ organizationId: undefined }); + + await waitFor(() => { + expect(resolved).toBe(true); + }); + + await savePromise; + }); + + it("fails authenticated client-config saves when the remote echo times out", async () => { + vi.useFakeTimers(); + + const savedConfig: WorkspaceClientConfig = { + version: 1, + clientCapabilities: { + experimental: { + inspectorProfile: true, + }, + }, + hostContext: {}, + }; + + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + clientConfig: undefined, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + const appState = createAppState({ + default: createSyntheticDefaultWorkspace(), + }); + const { result } = renderUseWorkspaceState({ appState }); + + const savePromise = result.current.handleUpdateClientConfig( + "remote-1", + savedConfig, + ); + const saveError = savePromise.catch((error) => error); + + await Promise.resolve(); + expect(updateClientConfigMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + + await expect(saveError).resolves.toBeInstanceOf(Error); + await expect(savePromise).rejects.toThrow( + "Timed out waiting for workspace client config to sync.", + ); + expect(useClientConfigStore.getState().isAwaitingRemoteEcho).toBe(false); + expect(useClientConfigStore.getState().isSaving).toBe(false); + }); }); diff --git a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts index 1b1c06917..8ed6c27de 100644 --- a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts +++ b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts @@ -6,6 +6,7 @@ interface UseHostedApiContextOptions { workspaceId: string | null; serverIdsByName: Record; clientCapabilities?: Record; + clientConfigSyncPending?: boolean; getAccessToken: () => Promise; oauthTokensByServerId?: Record; guestOauthTokensByServerName?: Record; @@ -21,6 +22,7 @@ export function useHostedApiContext({ workspaceId, serverIdsByName, clientCapabilities, + clientConfigSyncPending, getAccessToken, oauthTokensByServerId, guestOauthTokensByServerName, @@ -49,6 +51,7 @@ export function useHostedApiContext({ workspaceId, serverIdsByName, clientCapabilities, + clientConfigSyncPending, getAccessToken, oauthTokensByServerId, guestOauthTokensByServerName, @@ -66,6 +69,7 @@ export function useHostedApiContext({ workspaceId, serverIdsByName, clientCapabilities, + clientConfigSyncPending, getAccessToken, oauthTokensByServerId, guestOauthTokensByServerName, diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index 4068343ee..9df9b4d3c 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -31,9 +31,13 @@ import { HOSTED_MODE } from "@/lib/config"; import { injectHostedServerMapping } from "@/lib/apis/web/context"; import type { OAuthTestProfile } from "@/lib/oauth/profile"; import { authFetch } from "@/lib/session-token"; +import { useClientConfigStore } from "@/stores/client-config-store"; import { useUIPlaygroundStore } from "@/stores/ui-playground-store"; import { useServerMutations, type RemoteServer } from "./useWorkspaces"; -import { mergeWorkspaceClientCapabilities } from "@/lib/client-config"; +import { + CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE, + getEffectiveServerClientCapabilities, +} from "@/lib/client-config"; /** * Saves OAuth-related configuration to localStorage for reconnection purposes. @@ -196,24 +200,60 @@ export function useServerState({ return activeWorkspace?.servers || {}; }, [activeWorkspace]); - const activeWorkspaceClientCapabilities = useMemo( - () => activeWorkspace?.clientConfig?.clientCapabilities, - [activeWorkspace?.clientConfig?.clientCapabilities], + const isClientConfigSyncPending = useClientConfigStore( + (state) => + state.isAwaitingRemoteEcho && + state.pendingWorkspaceId === effectiveActiveWorkspaceId, ); const withWorkspaceClientCapabilities = useCallback( (serverConfig: MCPServerConfig): MCPServerConfig => { - const mergedCapabilities = mergeWorkspaceClientCapabilities( - serverConfig.capabilities as Record | undefined, - activeWorkspaceClientCapabilities, - ); + const mergedCapabilities = getEffectiveServerClientCapabilities({ + workspaceClientConfig: activeWorkspace?.clientConfig, + serverCapabilities: serverConfig.capabilities as + | Record + | undefined, + }); return { ...serverConfig, capabilities: mergedCapabilities, }; }, - [activeWorkspaceClientCapabilities], + [activeWorkspace?.clientConfig], + ); + + const assertClientConfigSynced = useCallback(() => { + if (!isClientConfigSyncPending) { + return; + } + + throw new Error(CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE); + }, [isClientConfigSyncPending]); + + const notifyIfClientConfigSyncPending = useCallback(() => { + if (!isClientConfigSyncPending) { + return false; + } + + toast.error(CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE); + return true; + }, [isClientConfigSyncPending]); + + const guardedTestConnection = useCallback( + async (serverConfig: MCPServerConfig, serverName: string) => { + assertClientConfigSynced(); + return testConnection(serverConfig, serverName); + }, + [assertClientConfigSynced], + ); + + const guardedReconnectServer = useCallback( + async (serverName: string, serverConfig: MCPServerConfig) => { + assertClientConfigSynced(); + return reconnectServer(serverName, serverConfig); + }, + [assertClientConfigSynced], ); const validateForm = (formData: ServerFormData): string | null => { @@ -451,7 +491,7 @@ export function useServerState({ }); try { - const connectionResult = await testConnection( + const connectionResult = await guardedTestConnection( withWorkspaceClientCapabilities(result.serverConfig), serverName, ); @@ -530,6 +570,7 @@ export function useServerState({ failPendingOAuthConnection, logger, storeInitInfo, + guardedTestConnection, withWorkspaceClientCapabilities, ], ); @@ -611,6 +652,10 @@ export function useServerState({ const handleConnect = useCallback( async (formData: ServerFormData) => { + if (notifyIfClientConfigSyncPending()) { + return; + } + const validationError = validateForm(formData); if (validationError) { toast.error(validationError); @@ -692,7 +737,7 @@ export function useServerState({ }, }, } satisfies HttpServerConfig; - const connectionResult = await testConnection( + const connectionResult = await guardedTestConnection( withWorkspaceClientCapabilities(serverConfig), formData.name, ); @@ -748,7 +793,7 @@ export function useServerState({ const oauthResult = await initiateOAuth(oauthOptions); if (oauthResult.success) { if (oauthResult.serverConfig) { - const connectionResult = await testConnection( + const connectionResult = await guardedTestConnection( withWorkspaceClientCapabilities(oauthResult.serverConfig), formData.name, ); @@ -804,7 +849,10 @@ export function useServerState({ clearOAuthData(formData.name); } const effectiveConfig = withWorkspaceClientCapabilities(mcpConfig); - const result = await testConnection(effectiveConfig, formData.name); + const result = await guardedTestConnection( + effectiveConfig, + formData.name, + ); if (isStaleOp(formData.name, token)) return; if (result.success) { dispatch({ @@ -860,9 +908,11 @@ export function useServerState({ isAuthenticated, appState.workspaces, appState.activeWorkspaceId, + notifyIfClientConfigSyncPending, syncServerToConvex, logger, storeInitInfo, + guardedTestConnection, withWorkspaceClientCapabilities, ], ); @@ -1011,7 +1061,7 @@ export function useServerState({ const token = nextOpToken(serverName); try { - const result = await reconnectServer( + const result = await guardedReconnectServer( serverName, withWorkspaceClientCapabilities(serverConfig), ); @@ -1048,7 +1098,12 @@ export function useServerState({ return { success: false, error: errorMessage }; } }, - [dispatch, storeInitInfo, withWorkspaceClientCapabilities], + [ + dispatch, + storeInitInfo, + guardedReconnectServer, + withWorkspaceClientCapabilities, + ], ); const handleConnectWithTokensFromOAuthFlow = useCallback( @@ -1064,6 +1119,10 @@ export function useServerState({ }, serverUrl: string, ) => { + if (notifyIfClientConfigSyncPending()) { + return; + } + const result = await applyTokensFromOAuthFlow( serverName, tokens, @@ -1075,7 +1134,7 @@ export function useServerState({ toast.error(`Connection failed: ${result.error}`); } }, - [applyTokensFromOAuthFlow], + [applyTokensFromOAuthFlow, notifyIfClientConfigSyncPending], ); const handleRefreshTokensFromOAuthFlow = useCallback( @@ -1091,6 +1150,10 @@ export function useServerState({ }, serverUrl: string, ) => { + if (notifyIfClientConfigSyncPending()) { + return; + } + const result = await applyTokensFromOAuthFlow( serverName, tokens, @@ -1102,7 +1165,7 @@ export function useServerState({ toast.error(`Token refresh failed: ${result.error}`); } }, - [applyTokensFromOAuthFlow], + [applyTokensFromOAuthFlow, notifyIfClientConfigSyncPending], ); const cliConfigProcessedRef = useRef(false); @@ -1276,6 +1339,10 @@ export function useServerState({ const handleReconnect = useCallback( async (serverName: string, options?: { forceOAuthFlow?: boolean }) => { + if (notifyIfClientConfigSyncPending()) { + return; + } + logger.info("Reconnecting to server", { serverName, options }); const server = effectiveServers[serverName]; if (!server) { @@ -1332,7 +1399,7 @@ export function useServerState({ toast.error(`OAuth failed: ${serverName}`); return; } - const result = await reconnectServer( + const result = await guardedReconnectServer( serverName, withWorkspaceClientCapabilities(oauthResult.serverConfig!), ); @@ -1374,7 +1441,7 @@ export function useServerState({ toast.error(`Failed to connect: ${serverName}`); return; } - const result = await reconnectServer( + const result = await guardedReconnectServer( serverName, withWorkspaceClientCapabilities(authResult.serverConfig), ); @@ -1421,6 +1488,8 @@ export function useServerState({ storeInitInfo, logger, dispatch, + notifyIfClientConfigSyncPending, + guardedReconnectServer, withWorkspaceClientCapabilities, ], ); @@ -1555,6 +1624,10 @@ export function useServerState({ } const hadOAuthTokens = originalServer?.oauthTokens != null; + if (notifyIfClientConfigSyncPending()) { + return { ok: false, serverName: originalServerName }; + } + const shouldPreserveOAuth = hadOAuthTokens && formData.useOAuth && @@ -1571,7 +1644,7 @@ export function useServerState({ }); saveOAuthConfigToLocalStorage(formData); try { - const result = await testConnection( + const result = await guardedTestConnection( withWorkspaceClientCapabilities(originalServer.config), originalServerName, ); @@ -1633,6 +1706,8 @@ export function useServerState({ removeServerFromStateAndCloud, setSelectedServer, syncServerToConvex, + notifyIfClientConfigSyncPending, + guardedTestConnection, ], ); diff --git a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index 84d09b34c..6635eec11 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -17,7 +17,27 @@ import { deserializeServersFromConvex, serializeServersForSharing, } from "@/lib/workspace-serialization"; -import type { WorkspaceClientConfig } from "@/lib/client-config"; +import { + stableStringifyJson, + type WorkspaceClientConfig, +} from "@/lib/client-config"; +import { useClientConfigStore } from "@/stores/client-config-store"; + +const CLIENT_CONFIG_SYNC_ECHO_TIMEOUT_MS = 10000; + +function stringifyWorkspaceClientConfig( + clientConfig: WorkspaceClientConfig | undefined, +) { + return stableStringifyJson(clientConfig ?? null); +} + +interface PendingClientConfigSync { + workspaceId: string; + expectedSerializedConfig: string; + resolve: () => void; + reject: (error: Error) => void; + timeoutId: ReturnType; +} interface LoggerLike { info: (message: string, meta?: Record) => void; @@ -89,8 +109,28 @@ export function useWorkspaceState({ const ensureDefaultInFlightRef = useRef(new Set()); const [useLocalFallback, setUseLocalFallback] = useState(false); const convexTimeoutRef = useRef(null); + const pendingClientConfigSyncRef = useRef( + null, + ); const CONVEX_TIMEOUT_MS = 10000; + const clearPendingClientConfigSync = useCallback( + (error?: Error) => { + const pending = pendingClientConfigSyncRef.current; + if (!pending) { + return; + } + + clearTimeout(pending.timeoutId); + pendingClientConfigSyncRef.current = null; + + if (error) { + pending.reject(error); + } + }, + [], + ); + useEffect(() => { if (!isAuthenticated) { setUseLocalFallback(false); @@ -131,6 +171,22 @@ export function useWorkspaceState({ }; }, [isAuthenticated, remoteWorkspaces, useLocalFallback, logger]); + useEffect(() => { + if (isAuthenticated && !useLocalFallback) { + return; + } + + clearPendingClientConfigSync( + new Error("Workspace client config sync was interrupted."), + ); + }, [clearPendingClientConfigSync, isAuthenticated, useLocalFallback]); + + useEffect(() => { + return () => { + clearPendingClientConfigSync(); + }; + }, [clearPendingClientConfigSync]); + const isLoadingRemoteWorkspaces = (isAuthenticated && !useLocalFallback && @@ -175,6 +231,26 @@ export function useWorkspaceState({ ); }, [remoteWorkspaces, convexActiveWorkspaceId, activeWorkspaceServersFlat]); + useEffect(() => { + const pending = pendingClientConfigSyncRef.current; + if (!pending) { + return; + } + + const syncedClientConfig = + convexWorkspaces[pending.workspaceId]?.clientConfig ?? undefined; + if ( + stringifyWorkspaceClientConfig(syncedClientConfig) !== + pending.expectedSerializedConfig + ) { + return; + } + + clearTimeout(pending.timeoutId); + pendingClientConfigSyncRef.current = null; + pending.resolve(); + }, [convexWorkspaces]); + const effectiveWorkspaces = useMemo((): Record => { if (useLocalFallback) { return appState.workspaces; @@ -460,13 +536,47 @@ export function useWorkspaceState({ workspaceId: string, clientConfig: WorkspaceClientConfig | undefined, ): Promise => { - if (isAuthenticated) { + const clientConfigStore = useClientConfigStore.getState(); + const awaitRemoteEcho = isAuthenticated && !useLocalFallback; + + clientConfigStore.beginSave({ + workspaceId, + savedConfig: clientConfig, + awaitRemoteEcho, + }); + + if (awaitRemoteEcho) { + const remoteEchoPromise = new Promise((resolve, reject) => { + clearPendingClientConfigSync(); + + const timeoutId = setTimeout(() => { + pendingClientConfigSyncRef.current = null; + reject( + new Error( + "Timed out waiting for workspace client config to sync.", + ), + ); + }, CLIENT_CONFIG_SYNC_ECHO_TIMEOUT_MS); + + pendingClientConfigSyncRef.current = { + workspaceId, + expectedSerializedConfig: + stringifyWorkspaceClientConfig(clientConfig), + resolve, + reject, + timeoutId, + }; + }); + try { await convexUpdateClientConfig({ workspaceId, clientConfig, }); + await remoteEchoPromise; } catch (error) { + clearPendingClientConfigSync(); + clientConfigStore.failSave(); const errorMessage = error instanceof Error ? error.message : "Unknown error"; logger.error("Failed to update workspace client config", { @@ -484,8 +594,16 @@ export function useWorkspaceState({ workspaceId, updates: { clientConfig }, }); + clientConfigStore.markSaved(clientConfig); }, - [isAuthenticated, convexUpdateClientConfig, logger, dispatch], + [ + isAuthenticated, + useLocalFallback, + convexUpdateClientConfig, + clearPendingClientConfigSync, + logger, + dispatch, + ], ); const handleDeleteWorkspace = useCallback( diff --git a/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts b/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts index b01675406..c7c7601c9 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; +import { CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE } from "../client-config"; vi.mock("../config", () => ({ HOSTED_MODE: true, @@ -210,6 +211,41 @@ describe("hosted web context", () => { }); }); + it("blocks hosted workspace requests while client config sync is pending", () => { + setHostedApiContext({ + workspaceId: "ws_pending", + serverIdsByName: { bench: "srv_bench" }, + clientConfigSyncPending: true, + getAccessToken: async () => null, + }); + + expect(() => buildHostedServerRequest("bench")).toThrow( + CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE, + ); + expect(() => buildHostedServerBatchRequest(["bench"])).toThrow( + CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE, + ); + }); + + it("keeps direct guest requests working while sync-pending gating is enabled elsewhere", () => { + setHostedApiContext({ + workspaceId: null, + isAuthenticated: false, + clientConfigSyncPending: true, + serverIdsByName: {}, + serverConfigs: { + myServer: { + url: "https://example.com/mcp", + }, + }, + }); + + expect(buildHostedServerRequest("myServer")).toEqual({ + serverUrl: "https://example.com/mcp", + clientCapabilities: defaultClientCapabilities, + }); + }); + it("throws when guest server config is not found", () => { setHostedApiContext({ workspaceId: null, diff --git a/mcpjam-inspector/client/src/lib/apis/web/context.ts b/mcpjam-inspector/client/src/lib/apis/web/context.ts index e63ca91f6..6ad0c4ee1 100644 --- a/mcpjam-inspector/client/src/lib/apis/web/context.ts +++ b/mcpjam-inspector/client/src/lib/apis/web/context.ts @@ -1,5 +1,6 @@ import { HOSTED_MODE } from "@/lib/config"; import { getGuestBearerToken } from "@/lib/guest-session"; +import { CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE } from "@/lib/client-config"; import { getDefaultClientCapabilities } from "@mcpjam/sdk/browser"; type GetAccessTokenFn = () => Promise; @@ -8,6 +9,7 @@ export interface HostedApiContext { workspaceId: string | null; serverIdsByName: Record; clientCapabilities?: Record; + clientConfigSyncPending?: boolean; getAccessToken?: GetAccessTokenFn; oauthTokensByServerId?: Record; guestOauthTokensByServerName?: Record; @@ -65,6 +67,14 @@ function assertHostedMode() { } } +function assertHostedClientConfigSynced() { + if (!hostedApiContext.clientConfigSyncPending) { + return; + } + + throw new Error(CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE); +} + /** * True when running in hosted mode as a direct guest connection. * Direct guests store server configs in localStorage and connect directly @@ -251,6 +261,7 @@ export function buildHostedServerRequest( } // Authenticated path: resolve via Convex server mappings + assertHostedClientConfigSynced(); const serverId = resolveHostedServerId(serverNameOrId); const oauthToken = getHostedOAuthToken(serverId); const shareToken = getHostedShareToken(); @@ -278,6 +289,7 @@ export function buildHostedServerBatchRequest(serverNamesOrIds: string[]): { shareToken?: string; sandboxToken?: string; } { + assertHostedClientConfigSynced(); const serverIds = resolveHostedServerIds(serverNamesOrIds); const oauthTokens = buildHostedOAuthTokensMap(serverIds); const shareToken = getHostedShareToken(); diff --git a/mcpjam-inspector/client/src/lib/client-config.ts b/mcpjam-inspector/client/src/lib/client-config.ts index 7315b140b..7e37448fb 100644 --- a/mcpjam-inspector/client/src/lib/client-config.ts +++ b/mcpjam-inspector/client/src/lib/client-config.ts @@ -25,6 +25,9 @@ export type HostSafeAreaInsets = { left: number; }; +export const CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE = + "Workspace client config is still syncing. Try again in a moment."; + export const DEFAULT_HOST_DEVICE_CAPABILITIES: HostDeviceCapabilities = { hover: true, touch: false, @@ -103,12 +106,38 @@ export function sanitizeWorkspaceClientConfig( } export function mergeWorkspaceClientCapabilities( - serverCapabilities?: Record, workspaceCapabilities?: Record, + serverCapabilities?: Record, ): ClientCapabilityOptions { return mergeClientCapabilities( - serverCapabilities as ClientCapabilityOptions | undefined, workspaceCapabilities as ClientCapabilityOptions | undefined, + serverCapabilities as ClientCapabilityOptions | undefined, + ); +} + +export function getEffectiveWorkspaceClientCapabilities( + workspaceClientConfig?: Pick | null, +): ClientCapabilityOptions { + return normalizeWorkspaceClientCapabilities( + (workspaceClientConfig?.clientCapabilities as + | Record + | undefined) ?? + (getDefaultClientCapabilities() as Record), + ); +} + +export function getEffectiveServerClientCapabilities(args: { + workspaceClientConfig?: Pick | null; + workspaceCapabilities?: Record; + serverCapabilities?: Record; +}): ClientCapabilityOptions { + const workspaceCapabilities = + args.workspaceCapabilities ?? + getEffectiveWorkspaceClientCapabilities(args.workspaceClientConfig); + + return mergeWorkspaceClientCapabilities( + workspaceCapabilities as Record, + args.serverCapabilities, ); } @@ -120,15 +149,35 @@ export function normalizeWorkspaceClientCapabilities( ); } +function canonicalizeJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeJsonValue); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nestedValue]) => [key, canonicalizeJsonValue(nestedValue)]), + ); + } + + return value; +} + +export function stableStringifyJson(value: unknown): string { + return JSON.stringify(canonicalizeJsonValue(value)); +} + export function workspaceClientCapabilitiesNeedReconnect(args: { desiredCapabilities?: Record; initializedCapabilities?: Record; }): boolean { return ( - stringifyJson( + stableStringifyJson( normalizeWorkspaceClientCapabilities(args.desiredCapabilities), ) !== - stringifyJson( + stableStringifyJson( normalizeWorkspaceClientCapabilities(args.initializedCapabilities), ) ); @@ -249,7 +298,3 @@ function isHostDisplayMode(value: unknown): value is HostDisplayMode { function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } - -function stringifyJson(value: unknown): string { - return JSON.stringify(value, null, 2); -} diff --git a/mcpjam-inspector/client/src/state/mcp-api.ts b/mcpjam-inspector/client/src/state/mcp-api.ts index d6e9de9b3..95cb9c967 100644 --- a/mcpjam-inspector/client/src/state/mcp-api.ts +++ b/mcpjam-inspector/client/src/state/mcp-api.ts @@ -52,8 +52,7 @@ async function safeValidateHostedServer( return await validateHostedServer( serverId, extractOAuthToken(serverConfig), - (serverConfig.capabilities as Record | undefined) ?? - undefined, + serverConfig.capabilities as Record | undefined, ); } catch (error) { return { diff --git a/mcpjam-inspector/client/src/stores/client-config-store.ts b/mcpjam-inspector/client/src/stores/client-config-store.ts index a7a255497..626876127 100644 --- a/mcpjam-inspector/client/src/stores/client-config-store.ts +++ b/mcpjam-inspector/client/src/stores/client-config-store.ts @@ -1,5 +1,8 @@ import { create } from "zustand"; -import type { WorkspaceClientConfig } from "@/lib/client-config"; +import { + stableStringifyJson, + type WorkspaceClientConfig, +} from "@/lib/client-config"; type JsonSection = "clientCapabilities" | "hostContext"; @@ -14,6 +17,9 @@ interface ClientConfigStoreState { hostContextError: string | null; isSaving: boolean; isDirty: boolean; + pendingWorkspaceId: string | null; + pendingSavedConfig: WorkspaceClientConfig | undefined; + isAwaitingRemoteEcho: boolean; loadWorkspaceConfig: (input: { workspaceId: string | null; defaultConfig: WorkspaceClientConfig | null; @@ -23,8 +29,13 @@ interface ClientConfigStoreState { patchHostContext: (patch: Record) => void; resetSectionToDefault: (section: JsonSection) => void; resetToBaseline: () => void; - markSaving: (isSaving: boolean) => void; + beginSave: (input: { + workspaceId: string; + savedConfig: WorkspaceClientConfig | undefined; + awaitRemoteEcho: boolean; + }) => void; markSaved: (savedConfig: WorkspaceClientConfig | undefined) => void; + failSave: () => void; } function stringifyJson(value: unknown): string { @@ -38,8 +49,9 @@ function createInitialState(): Omit< | "patchHostContext" | "resetSectionToDefault" | "resetToBaseline" - | "markSaving" + | "beginSave" | "markSaved" + | "failSave" > { return { activeWorkspaceId: null, @@ -52,6 +64,9 @@ function createInitialState(): Omit< hostContextError: null, isSaving: false, isDirty: false, + pendingWorkspaceId: null, + pendingSavedConfig: undefined, + isAwaitingRemoteEcho: false, }; } @@ -72,7 +87,7 @@ function computeDirtyState( return false; } - return stringifyJson(state.draftConfig) !== stringifyJson(baseline); + return stableStringifyJson(state.draftConfig) !== stableStringifyJson(baseline); } function resetFromConfig( @@ -90,10 +105,30 @@ function resetFromConfig( hostContextText: stringifyJson(baseline?.hostContext ?? {}), clientCapabilitiesError: null, hostContextError: null, + pendingWorkspaceId: null, + pendingSavedConfig: undefined, + isAwaitingRemoteEcho: false, + isSaving: false, isDirty: false, }; } +function isPendingRemoteEchoMatch( + state: Pick< + ClientConfigStoreState, + "isAwaitingRemoteEcho" | "pendingWorkspaceId" | "pendingSavedConfig" + >, + workspaceId: string | null, + savedConfig?: WorkspaceClientConfig, +) { + return ( + state.isAwaitingRemoteEcho && + state.pendingWorkspaceId === workspaceId && + stableStringifyJson(state.pendingSavedConfig) === + stableStringifyJson(savedConfig) + ); +} + function parseRecordJson(text: string): Record { const parsed = JSON.parse(text) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { @@ -132,13 +167,33 @@ export const useClientConfigStore = create( loadWorkspaceConfig: ({ workspaceId, defaultConfig, savedConfig }) => { const state = get(); + const shouldApplyPendingRemoteEcho = isPendingRemoteEchoMatch( + state, + workspaceId, + savedConfig, + ); + + if ( + state.isDirty && + state.activeWorkspaceId === workspaceId && + !shouldApplyPendingRemoteEcho + ) { + return; + } const sameWorkspace = state.activeWorkspaceId === workspaceId; const sameDefault = - stringifyJson(state.defaultConfig) === stringifyJson(defaultConfig); + stableStringifyJson(state.defaultConfig) === + stableStringifyJson(defaultConfig); const sameSaved = - stringifyJson(state.savedConfig) === stringifyJson(savedConfig); + stableStringifyJson(state.savedConfig) === + stableStringifyJson(savedConfig); - if (sameWorkspace && sameDefault && sameSaved) { + if ( + sameWorkspace && + sameDefault && + sameSaved && + !shouldApplyPendingRemoteEcho + ) { return; } @@ -227,17 +282,34 @@ export const useClientConfigStore = create( ); }, - markSaving: (isSaving) => set({ isSaving }), + beginSave: ({ workspaceId, savedConfig, awaitRemoteEcho }) => + set({ + isSaving: true, + pendingWorkspaceId: awaitRemoteEcho ? workspaceId : null, + pendingSavedConfig: awaitRemoteEcho ? savedConfig : undefined, + isAwaitingRemoteEcho: awaitRemoteEcho, + }), markSaved: (savedConfig) => set((state) => ({ savedConfig, isSaving: false, + pendingWorkspaceId: null, + pendingSavedConfig: undefined, + isAwaitingRemoteEcho: false, isDirty: computeDirtyState({ defaultConfig: state.defaultConfig, savedConfig, draftConfig: state.draftConfig, }), })), + + failSave: () => + set({ + isSaving: false, + pendingWorkspaceId: null, + pendingSavedConfig: undefined, + isAwaitingRemoteEcho: false, + }), }), ); diff --git a/sdk/src/mcp-client-manager/capabilities.ts b/sdk/src/mcp-client-manager/capabilities.ts index 4866536b1..2fcbbabe9 100644 --- a/sdk/src/mcp-client-manager/capabilities.ts +++ b/sdk/src/mcp-client-manager/capabilities.ts @@ -3,6 +3,42 @@ import type { ClientCapabilityOptions } from "./types.js"; export const MCP_UI_EXTENSION_ID = "io.modelcontextprotocol/ui"; export const MCP_UI_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Merges MCP `extensions` maps by extension id so workspace/server partial + * configs do not replace the entire `extensions` object (which would drop + * unrelated extension entries). + */ +function mergeExtensionsMaps( + base?: Record, + override?: Record, +): Record | undefined { + if (!base && !override) { + return undefined; + } + if (!base) { + return { ...override }; + } + if (!override) { + return { ...base }; + } + + const merged: Record = { ...base }; + for (const key of Object.keys(override)) { + const b = merged[key]; + const o = override[key]; + if (isPlainObject(b) && isPlainObject(o)) { + merged[key] = { ...b, ...o }; + } else { + merged[key] = o; + } + } + return merged; +} + export function getDefaultClientCapabilities(): ClientCapabilityOptions { return { extensions: { @@ -31,8 +67,33 @@ export function mergeClientCapabilities( base?: ClientCapabilityOptions, overrides?: ClientCapabilityOptions, ): ClientCapabilityOptions { - return normalizeClientCapabilities({ + const merged: Record = { ...(base ?? {}), ...(overrides ?? {}), - } as ClientCapabilityOptions); + }; + + if ( + overrides && + Object.prototype.hasOwnProperty.call(overrides, "extensions") + ) { + const overExt = overrides.extensions; + if (overExt === undefined) { + merged.extensions = base?.extensions; + } else if (isPlainObject(overExt)) { + if (Object.keys(overExt).length === 0) { + merged.extensions = {}; + } else if (isPlainObject(base?.extensions)) { + merged.extensions = mergeExtensionsMaps( + base!.extensions as Record, + overExt, + ); + } else { + merged.extensions = { ...overExt }; + } + } else { + merged.extensions = overExt; + } + } + + return normalizeClientCapabilities(merged as ClientCapabilityOptions); } diff --git a/sdk/tests/browser-entry.test.ts b/sdk/tests/browser-entry.test.ts index b4b1f6c21..4af3e8167 100644 --- a/sdk/tests/browser-entry.test.ts +++ b/sdk/tests/browser-entry.test.ts @@ -1,6 +1,41 @@ import * as browser from "../src/browser"; describe("browser entrypoint", () => { + it("deep-merges extensions by extension id when merging client capabilities", () => { + const ui = browser.MCP_UI_EXTENSION_ID; + const merged = browser.mergeClientCapabilities( + { + extensions: { + [ui]: { mimeTypes: ["text/html;profile=mcp-app"] }, + "custom/ext": { foo: 1 }, + }, + } as any, + { + extensions: { + [ui]: { extra: true }, + }, + } as any, + ); + + const extensions = (merged as Record).extensions as Record< + string, + unknown + >; + expect(extensions[ui]).toEqual({ + mimeTypes: ["text/html;profile=mcp-app"], + extra: true, + }); + expect(extensions["custom/ext"]).toEqual({ foo: 1 }); + }); + + it("treats explicit empty extensions object as a full clear", () => { + const merged = browser.mergeClientCapabilities( + browser.getDefaultClientCapabilities(), + { extensions: {} } as any, + ); + expect((merged as Record).extensions).toEqual({}); + }); + it("exports browser-safe capability helpers without MCPClientManager", () => { expect(browser.MCP_UI_EXTENSION_ID).toBe("io.modelcontextprotocol/ui"); expect(browser.MCP_UI_RESOURCE_MIME_TYPE).toBe("text/html;profile=mcp-app"); From 9dae8e946acba8d2fa1552c29804ba37799f3918 Mon Sep 17 00:00:00 2001 From: chelojimenez <58269507+chelojimenez@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:39:39 +0000 Subject: [PATCH 09/14] style: auto-fix prettier formatting --- .../client/src/components/ServersTab.tsx | 4 +-- .../hooks/__tests__/use-server-state.test.tsx | 4 +-- .../client/src/hooks/use-workspace-state.ts | 25 ++++++++----------- .../client/src/lib/client-config.ts | 10 ++++++-- .../client/src/stores/client-config-store.ts | 4 ++- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index 5a22934d9..5ccb51265 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -247,8 +247,8 @@ export function ServersTab({ server.connectionStatus === "connected" && workspaceClientCapabilitiesNeedReconnect({ desiredCapabilities: getEffectiveServerClientCapabilities({ - workspaceClientConfig: workspaces[activeWorkspaceId] - ?.clientConfig, + workspaceClientConfig: + workspaces[activeWorkspaceId]?.clientConfig, serverCapabilities: server.config.capabilities as | Record | undefined, diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx index bdaf1d475..17ae8b194 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx @@ -230,9 +230,7 @@ describe("useServerState OAuth callback failures", () => { CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE, ); expect( - dispatch.mock.calls.some( - ([action]) => action.type === "CONNECT_REQUEST", - ), + dispatch.mock.calls.some(([action]) => action.type === "CONNECT_REQUEST"), ).toBe(false); }); }); diff --git a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index 6635eec11..8e4fe78ac 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -114,22 +114,19 @@ export function useWorkspaceState({ ); const CONVEX_TIMEOUT_MS = 10000; - const clearPendingClientConfigSync = useCallback( - (error?: Error) => { - const pending = pendingClientConfigSyncRef.current; - if (!pending) { - return; - } + const clearPendingClientConfigSync = useCallback((error?: Error) => { + const pending = pendingClientConfigSyncRef.current; + if (!pending) { + return; + } - clearTimeout(pending.timeoutId); - pendingClientConfigSyncRef.current = null; + clearTimeout(pending.timeoutId); + pendingClientConfigSyncRef.current = null; - if (error) { - pending.reject(error); - } - }, - [], - ); + if (error) { + pending.reject(error); + } + }, []); useEffect(() => { if (!isAuthenticated) { diff --git a/mcpjam-inspector/client/src/lib/client-config.ts b/mcpjam-inspector/client/src/lib/client-config.ts index 7e37448fb..d10bbf28b 100644 --- a/mcpjam-inspector/client/src/lib/client-config.ts +++ b/mcpjam-inspector/client/src/lib/client-config.ts @@ -116,7 +116,10 @@ export function mergeWorkspaceClientCapabilities( } export function getEffectiveWorkspaceClientCapabilities( - workspaceClientConfig?: Pick | null, + workspaceClientConfig?: Pick< + WorkspaceClientConfig, + "clientCapabilities" + > | null, ): ClientCapabilityOptions { return normalizeWorkspaceClientCapabilities( (workspaceClientConfig?.clientCapabilities as @@ -127,7 +130,10 @@ export function getEffectiveWorkspaceClientCapabilities( } export function getEffectiveServerClientCapabilities(args: { - workspaceClientConfig?: Pick | null; + workspaceClientConfig?: Pick< + WorkspaceClientConfig, + "clientCapabilities" + > | null; workspaceCapabilities?: Record; serverCapabilities?: Record; }): ClientCapabilityOptions { diff --git a/mcpjam-inspector/client/src/stores/client-config-store.ts b/mcpjam-inspector/client/src/stores/client-config-store.ts index 626876127..248fe699c 100644 --- a/mcpjam-inspector/client/src/stores/client-config-store.ts +++ b/mcpjam-inspector/client/src/stores/client-config-store.ts @@ -87,7 +87,9 @@ function computeDirtyState( return false; } - return stableStringifyJson(state.draftConfig) !== stableStringifyJson(baseline); + return ( + stableStringifyJson(state.draftConfig) !== stableStringifyJson(baseline) + ); } function resetFromConfig( From 56a943bc0293f82c78a13772cbc52b96fbfa13c2 Mon Sep 17 00:00:00 2001 From: marcelo Date: Mon, 23 Mar 2026 23:58:00 -0700 Subject: [PATCH 10/14] prettier --- .../client/src/components/ServersTab.tsx | 4 +-- .../hooks/__tests__/use-server-state.test.tsx | 4 +-- .../client/src/hooks/use-workspace-state.ts | 25 ++++++++----------- .../client/src/lib/client-config.ts | 10 ++++++-- .../client/src/stores/client-config-store.ts | 4 ++- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index 5a22934d9..5ccb51265 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -247,8 +247,8 @@ export function ServersTab({ server.connectionStatus === "connected" && workspaceClientCapabilitiesNeedReconnect({ desiredCapabilities: getEffectiveServerClientCapabilities({ - workspaceClientConfig: workspaces[activeWorkspaceId] - ?.clientConfig, + workspaceClientConfig: + workspaces[activeWorkspaceId]?.clientConfig, serverCapabilities: server.config.capabilities as | Record | undefined, diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx index bdaf1d475..17ae8b194 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx @@ -230,9 +230,7 @@ describe("useServerState OAuth callback failures", () => { CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE, ); expect( - dispatch.mock.calls.some( - ([action]) => action.type === "CONNECT_REQUEST", - ), + dispatch.mock.calls.some(([action]) => action.type === "CONNECT_REQUEST"), ).toBe(false); }); }); diff --git a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index 6635eec11..8e4fe78ac 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -114,22 +114,19 @@ export function useWorkspaceState({ ); const CONVEX_TIMEOUT_MS = 10000; - const clearPendingClientConfigSync = useCallback( - (error?: Error) => { - const pending = pendingClientConfigSyncRef.current; - if (!pending) { - return; - } + const clearPendingClientConfigSync = useCallback((error?: Error) => { + const pending = pendingClientConfigSyncRef.current; + if (!pending) { + return; + } - clearTimeout(pending.timeoutId); - pendingClientConfigSyncRef.current = null; + clearTimeout(pending.timeoutId); + pendingClientConfigSyncRef.current = null; - if (error) { - pending.reject(error); - } - }, - [], - ); + if (error) { + pending.reject(error); + } + }, []); useEffect(() => { if (!isAuthenticated) { diff --git a/mcpjam-inspector/client/src/lib/client-config.ts b/mcpjam-inspector/client/src/lib/client-config.ts index 7e37448fb..d10bbf28b 100644 --- a/mcpjam-inspector/client/src/lib/client-config.ts +++ b/mcpjam-inspector/client/src/lib/client-config.ts @@ -116,7 +116,10 @@ export function mergeWorkspaceClientCapabilities( } export function getEffectiveWorkspaceClientCapabilities( - workspaceClientConfig?: Pick | null, + workspaceClientConfig?: Pick< + WorkspaceClientConfig, + "clientCapabilities" + > | null, ): ClientCapabilityOptions { return normalizeWorkspaceClientCapabilities( (workspaceClientConfig?.clientCapabilities as @@ -127,7 +130,10 @@ export function getEffectiveWorkspaceClientCapabilities( } export function getEffectiveServerClientCapabilities(args: { - workspaceClientConfig?: Pick | null; + workspaceClientConfig?: Pick< + WorkspaceClientConfig, + "clientCapabilities" + > | null; workspaceCapabilities?: Record; serverCapabilities?: Record; }): ClientCapabilityOptions { diff --git a/mcpjam-inspector/client/src/stores/client-config-store.ts b/mcpjam-inspector/client/src/stores/client-config-store.ts index 626876127..248fe699c 100644 --- a/mcpjam-inspector/client/src/stores/client-config-store.ts +++ b/mcpjam-inspector/client/src/stores/client-config-store.ts @@ -87,7 +87,9 @@ function computeDirtyState( return false; } - return stableStringifyJson(state.draftConfig) !== stableStringifyJson(baseline); + return ( + stableStringifyJson(state.draftConfig) !== stableStringifyJson(baseline) + ); } function resetFromConfig( From 6930e57336cfbdd412da2d4b2e895f31ad1a690b Mon Sep 17 00:00:00 2001 From: marcelo Date: Tue, 24 Mar 2026 00:27:11 -0700 Subject: [PATCH 11/14] nits --- mcpjam-inspector/client/src/App.tsx | 33 +----- .../src/__tests__/App.hosted-oauth.test.tsx | 3 + .../chat-v2/thread/chatgpt-app-renderer.tsx | 20 +++- .../thread/mcp-apps/mcp-apps-renderer.tsx | 3 +- .../WorkspaceClientConfigSync.tsx | 70 +++++++++++ .../WorkspaceClientConfigSync.test.tsx | 111 ++++++++++++++++++ .../shared/DisplayContextHeader.tsx | 4 +- .../__tests__/use-workspace-state.test.tsx | 53 +++++++++ .../client/src/hooks/use-workspace-state.ts | 4 +- 9 files changed, 265 insertions(+), 36 deletions(-) create mode 100644 mcpjam-inspector/client/src/components/client-config/WorkspaceClientConfigSync.tsx create mode 100644 mcpjam-inspector/client/src/components/client-config/__tests__/WorkspaceClientConfigSync.test.tsx diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index 3bd082a50..faddf8df8 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -18,6 +18,7 @@ import { SandboxesTab } from "./components/SandboxesTab"; import { SettingsTab } from "./components/SettingsTab"; import { WorkspaceSettingsTab } from "./components/WorkspaceSettingsTab"; import { ClientConfigTab } from "./components/client-config/ClientConfigTab"; +import { WorkspaceClientConfigSync } from "./components/client-config/WorkspaceClientConfigSync"; import { TracingTab } from "./components/TracingTab"; import { AuthTab } from "./components/AuthTab"; import { OAuthFlowTab } from "./components/OAuthFlowTab"; @@ -107,16 +108,12 @@ import { writeHostedOAuthResumeMarker, } from "./lib/hosted-oauth-resume"; import { handleOAuthCallback } from "./lib/oauth/mcp-oauth"; -import { - buildDefaultWorkspaceClientConfig, - getEffectiveWorkspaceClientCapabilities, -} from "./lib/client-config"; +import { getEffectiveWorkspaceClientCapabilities } from "./lib/client-config"; import type { BillingRolloutState, OrganizationEntitlements, } from "./hooks/useOrganizationBilling"; import { useClientConfigStore } from "./stores/client-config-store"; -import { useUIPlaygroundStore } from "./stores/ui-playground-store"; function getHostedOAuthCallbackErrorMessage(): string { const params = new URLSearchParams(window.location.search); @@ -376,11 +373,6 @@ export default function App() { const { sortedOrganizations, isLoading: isLoadingOrganizations } = useOrganizationQueries({ isAuthenticated }); - const playgroundGlobals = useUIPlaygroundStore((s) => s.globals); - const playgroundCapabilities = useUIPlaygroundStore((s) => s.capabilities); - const playgroundSafeAreaInsets = useUIPlaygroundStore( - (s) => s.safeAreaInsets, - ); const currentHash = window.location.hash || "#servers"; const currentHashRoute = useMemo( () => resolveHostedNavigation(currentHash, HOSTED_MODE), @@ -529,23 +521,6 @@ export default function App() { [appState.servers], ); - useEffect(() => { - const defaultClientConfig = buildDefaultWorkspaceClientConfig({ - theme: getInitialThemeMode(), - displayMode: playgroundGlobals.displayMode, - locale: playgroundGlobals.locale, - timeZone: playgroundGlobals.timeZone, - deviceCapabilities: playgroundCapabilities, - safeAreaInsets: playgroundSafeAreaInsets, - }); - - useClientConfigStore.getState().loadWorkspaceConfig({ - workspaceId: activeWorkspaceId, - defaultConfig: defaultClientConfig, - savedConfig: activeWorkspace?.clientConfig, - }); - }, [activeWorkspaceId, activeWorkspace?.clientConfig]); - useHostedApiContext({ workspaceId: convexWorkspaceId, serverIdsByName: hostedServerIdsByName, @@ -1055,6 +1030,10 @@ export default function App() { themeMode={initialThemeMode} themePreset={initialThemePreset} > + ({ vi.mock("../components/SettingsTab", () => ({ SettingsTab: () =>
, })); +vi.mock("../components/client-config/WorkspaceClientConfigSync", () => ({ + WorkspaceClientConfigSync: () => null, +})); vi.mock("../components/TracingTab", () => ({ TracingTab: () =>
, })); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx index 55a2e6f41..1b022006b 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx @@ -756,13 +756,21 @@ export function ChatGPTAppRenderer({ ? playgroundDeviceType : getDeviceType(); // Use stable default objects to avoid infinite re-renders in useWidgetFetch - const capabilities = extractHostDeviceCapabilities( - draftHostContext, - isPlaygroundActive ? playgroundCapabilities : DEFAULT_CAPABILITIES, + const capabilities = useMemo( + () => + extractHostDeviceCapabilities( + draftHostContext, + isPlaygroundActive ? playgroundCapabilities : DEFAULT_CAPABILITIES, + ), + [draftHostContext, isPlaygroundActive, playgroundCapabilities], ); - const safeAreaInsets = extractHostSafeAreaInsets( - draftHostContext, - isPlaygroundActive ? playgroundSafeAreaInsets : DEFAULT_SAFE_AREA_INSETS, + const safeAreaInsets = useMemo( + () => + extractHostSafeAreaInsets( + draftHostContext, + isPlaygroundActive ? playgroundSafeAreaInsets : DEFAULT_SAFE_AREA_INSETS, + ), + [draftHostContext, isPlaygroundActive, playgroundSafeAreaInsets], ); const setWidgetCsp = useWidgetDebugStore((s) => s.setWidgetCsp); const setWidgetHtml = useWidgetDebugStore((s) => s.setWidgetHtml); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx index 650ea0e40..8e2912c74 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx @@ -723,7 +723,8 @@ export function MCPAppsRenderer({ styles: baseHostContext.styles && typeof baseHostContext.styles === "object" && - !Array.isArray(baseHostContext.styles) + !Array.isArray(baseHostContext.styles) && + Object.keys(baseHostContext.styles as Record).length > 0 ? (baseHostContext.styles as McpUiHostContext["styles"]) : { variables: styleVariables, diff --git a/mcpjam-inspector/client/src/components/client-config/WorkspaceClientConfigSync.tsx b/mcpjam-inspector/client/src/components/client-config/WorkspaceClientConfigSync.tsx new file mode 100644 index 000000000..c6a92cf5e --- /dev/null +++ b/mcpjam-inspector/client/src/components/client-config/WorkspaceClientConfigSync.tsx @@ -0,0 +1,70 @@ +import { useEffect } from "react"; +import type { Workspace } from "@/state/app-types"; +import { buildDefaultWorkspaceClientConfig } from "@/lib/client-config"; +import { useClientConfigStore } from "@/stores/client-config-store"; +import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; +import { useUIPlaygroundStore } from "@/stores/ui-playground-store"; + +interface WorkspaceClientConfigSyncProps { + activeWorkspaceId: string; + savedClientConfig?: Workspace["clientConfig"]; +} + +export function WorkspaceClientConfigSync({ + activeWorkspaceId, + savedClientConfig, +}: WorkspaceClientConfigSyncProps) { + const themeMode = usePreferencesStore((state) => state.themeMode); + const displayMode = useUIPlaygroundStore( + (state) => state.globals.displayMode, + ); + const locale = useUIPlaygroundStore((state) => state.globals.locale); + const timeZone = useUIPlaygroundStore((state) => state.globals.timeZone); + const hover = useUIPlaygroundStore((state) => state.capabilities.hover); + const touch = useUIPlaygroundStore((state) => state.capabilities.touch); + const safeAreaTop = useUIPlaygroundStore((state) => state.safeAreaInsets.top); + const safeAreaRight = useUIPlaygroundStore( + (state) => state.safeAreaInsets.right, + ); + const safeAreaBottom = useUIPlaygroundStore( + (state) => state.safeAreaInsets.bottom, + ); + const safeAreaLeft = useUIPlaygroundStore( + (state) => state.safeAreaInsets.left, + ); + + useEffect(() => { + useClientConfigStore.getState().loadWorkspaceConfig({ + workspaceId: activeWorkspaceId, + defaultConfig: buildDefaultWorkspaceClientConfig({ + theme: themeMode, + displayMode, + locale, + timeZone, + deviceCapabilities: { hover, touch }, + safeAreaInsets: { + top: safeAreaTop, + right: safeAreaRight, + bottom: safeAreaBottom, + left: safeAreaLeft, + }, + }), + savedConfig: savedClientConfig, + }); + }, [ + activeWorkspaceId, + savedClientConfig, + themeMode, + displayMode, + locale, + timeZone, + hover, + touch, + safeAreaTop, + safeAreaRight, + safeAreaBottom, + safeAreaLeft, + ]); + + return null; +} diff --git a/mcpjam-inspector/client/src/components/client-config/__tests__/WorkspaceClientConfigSync.test.tsx b/mcpjam-inspector/client/src/components/client-config/__tests__/WorkspaceClientConfigSync.test.tsx new file mode 100644 index 000000000..646ac77b7 --- /dev/null +++ b/mcpjam-inspector/client/src/components/client-config/__tests__/WorkspaceClientConfigSync.test.tsx @@ -0,0 +1,111 @@ +import { useEffect } from "react"; +import { act, render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { WorkspaceClientConfigSync } from "../WorkspaceClientConfigSync"; +import { useClientConfigStore } from "@/stores/client-config-store"; +import { + PreferencesStoreProvider, + usePreferencesStore, +} from "@/stores/preferences/preferences-provider"; +import { useUIPlaygroundStore } from "@/stores/ui-playground-store"; + +function ThemeModeUpdater({ themeMode }: { themeMode: "light" | "dark" }) { + const setThemeMode = usePreferencesStore((state) => state.setThemeMode); + + useEffect(() => { + setThemeMode(themeMode); + }, [setThemeMode, themeMode]); + + return null; +} + +describe("WorkspaceClientConfigSync", () => { + beforeEach(() => { + localStorage.clear(); + useClientConfigStore.setState({ + activeWorkspaceId: null, + defaultConfig: null, + savedConfig: undefined, + draftConfig: null, + clientCapabilitiesText: "{}", + hostContextText: "{}", + clientCapabilitiesError: null, + hostContextError: null, + isSaving: false, + isDirty: false, + pendingWorkspaceId: null, + pendingSavedConfig: undefined, + isAwaitingRemoteEcho: false, + }); + useUIPlaygroundStore.getState().reset(); + }); + + it("refreshes unsaved workspace defaults when theme and playground context change", async () => { + const view = render( + + + + , + ); + + await waitFor(() => { + expect( + useClientConfigStore.getState().defaultConfig?.hostContext, + ).toMatchObject({ + theme: "light", + displayMode: "inline", + }); + }); + + act(() => { + useUIPlaygroundStore.getState().updateGlobal("displayMode", "fullscreen"); + useUIPlaygroundStore.getState().updateGlobal("locale", "fr-CA"); + useUIPlaygroundStore + .getState() + .updateGlobal("timeZone", "America/Toronto"); + useUIPlaygroundStore.getState().setCapabilities({ + hover: false, + touch: true, + }); + useUIPlaygroundStore.getState().setSafeAreaInsets({ + top: 44, + right: 8, + bottom: 12, + left: 6, + }); + }); + + view.rerender( + + + + , + ); + + await waitFor(() => { + expect( + useClientConfigStore.getState().defaultConfig?.hostContext, + ).toEqual( + expect.objectContaining({ + theme: "dark", + displayMode: "fullscreen", + locale: "fr-CA", + timeZone: "America/Toronto", + deviceCapabilities: { + hover: false, + touch: true, + }, + safeAreaInsets: { + top: 44, + right: 8, + bottom: 12, + left: 6, + }, + }), + ); + expect(useClientConfigStore.getState().draftConfig).toEqual( + useClientConfigStore.getState().defaultConfig, + ); + }); + }); +}); diff --git a/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx b/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx index e162ed5e6..23e9a25fa 100644 --- a/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx +++ b/mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx @@ -44,6 +44,7 @@ import { } from "@/stores/ui-playground-store"; import { useWidgetDebugStore } from "@/stores/widget-debug-store"; import { cn } from "@/lib/utils"; +import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; import { SafeAreaEditor } from "@/components/ui-playground/SafeAreaEditor"; import { UIType } from "@/lib/mcp-ui/mcp-apps-utils"; import { useClientConfigStore } from "@/stores/client-config-store"; @@ -209,7 +210,8 @@ export function DisplayContextHeader({ const fallbackLocale = navigator.language || "en-US"; const fallbackTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const theme = extractHostTheme(hostContext) ?? "dark"; + const themePreference = usePreferencesStore((s) => s.themeMode); + const theme = extractHostTheme(hostContext) ?? themePreference; const handleThemeChange = useCallback(() => { const newTheme = theme === "dark" ? "light" : "dark"; diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx index 7b800d5db..d7490ae70 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx @@ -400,4 +400,57 @@ describe("useWorkspaceState automatic workspace creation", () => { expect(useClientConfigStore.getState().isAwaitingRemoteEcho).toBe(false); expect(useClientConfigStore.getState().isSaving).toBe(false); }); + + it("rejects authenticated client-config saves when the hook unmounts mid-sync", async () => { + const savedConfig: WorkspaceClientConfig = { + version: 1, + clientCapabilities: { + experimental: { + inspectorProfile: true, + }, + }, + hostContext: {}, + }; + + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + clientConfig: undefined, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + const appState = createAppState({ + default: createSyntheticDefaultWorkspace(), + }); + const { result, unmount } = renderUseWorkspaceState({ appState }); + + const savePromise = result.current.handleUpdateClientConfig( + "remote-1", + savedConfig, + ); + + await waitFor(() => { + expect(updateClientConfigMock).toHaveBeenCalledWith({ + workspaceId: "remote-1", + clientConfig: savedConfig, + }); + }); + + unmount(); + + await expect(savePromise).rejects.toThrow( + "Workspace client config sync was interrupted.", + ); + await waitFor(() => { + expect(useClientConfigStore.getState().isAwaitingRemoteEcho).toBe(false); + expect(useClientConfigStore.getState().isSaving).toBe(false); + }); + }); }); diff --git a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index 8e4fe78ac..97100e2b7 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -180,7 +180,9 @@ export function useWorkspaceState({ useEffect(() => { return () => { - clearPendingClientConfigSync(); + clearPendingClientConfigSync( + new Error("Workspace client config sync was interrupted."), + ); }; }, [clearPendingClientConfigSync]); From ad1f02d7aced365d329ea9dac128c2ce59bb6c01 Mon Sep 17 00:00:00 2001 From: marcelo Date: Tue, 24 Mar 2026 00:33:33 -0700 Subject: [PATCH 12/14] compatibility fix --- .../hooks/__tests__/use-server-state.test.tsx | 75 ++++++++++++++- .../client/src/hooks/use-server-state.ts | 1 + .../mcp-client-manager/MCPClientManager.ts | 15 ++- sdk/src/mcp-client-manager/capabilities.ts | 14 +-- sdk/src/mcp-client-manager/types.ts | 7 +- sdk/tests/MCPClientManager.test.ts | 91 ++++++++++++++++--- 6 files changed, 179 insertions(+), 24 deletions(-) diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx index 17ae8b194..e60c1325f 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx @@ -2,6 +2,7 @@ import { renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AppState, AppAction } from "@/state/app-types"; import { CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE } from "@/lib/client-config"; +import type { WorkspaceClientConfig } from "@/lib/client-config"; import { useClientConfigStore } from "@/stores/client-config-store"; import { useServerState } from "../use-server-state"; @@ -65,18 +66,23 @@ vi.mock("../useWorkspaces", () => ({ }), })); -function createAppState(): AppState { +function createAppState(options?: { + workspaceClientConfig?: WorkspaceClientConfig; + serverCapabilities?: Record; +}): AppState { return { workspaces: { default: { id: "default", name: "Default", + clientConfig: options?.workspaceClientConfig, servers: { "demo-server": { name: "demo-server", config: { type: "http", url: "https://example.com/mcp", + capabilities: options?.serverCapabilities, } as any, lastConnectionTime: new Date(), connectionStatus: "connecting", @@ -97,6 +103,7 @@ function createAppState(): AppState { config: { type: "http", url: "https://example.com/mcp", + capabilities: options?.serverCapabilities, } as any, lastConnectionTime: new Date(), connectionStatus: "connecting", @@ -111,8 +118,10 @@ function createAppState(): AppState { }; } -function renderUseServerState(dispatch: (action: AppAction) => void) { - const appState = createAppState(); +function renderUseServerState( + dispatch: (action: AppAction) => void, + appState = createAppState(), +) { return renderHook(() => useServerState({ appState, @@ -233,4 +242,64 @@ describe("useServerState OAuth callback failures", () => { dispatch.mock.calls.some(([action]) => action.type === "CONNECT_REQUEST"), ).toBe(false); }); + + it("passes exact workspace-derived clientCapabilities on local reconnect", async () => { + const { reconnectServer } = await import("@/state/mcp-api"); + const { ensureAuthorizedForReconnect } = await import( + "@/state/oauth-orchestrator" + ); + vi.mocked(reconnectServer).mockResolvedValue({ + success: true, + initInfo: { + clientCapabilities: {}, + }, + } as any); + + const appState = createAppState({ + workspaceClientConfig: { + version: 1, + clientCapabilities: { + experimental: { + workspaceProfile: {}, + }, + }, + hostContext: {}, + }, + serverCapabilities: { + sampling: {}, + }, + }); + + const dispatch = vi.fn(); + const { result } = renderUseServerState(dispatch, appState); + vi.mocked(ensureAuthorizedForReconnect).mockResolvedValue({ + kind: "ready", + serverConfig: appState.workspaces.default.servers["demo-server"].config, + tokens: undefined, + } as any); + + await result.current.handleReconnect("demo-server"); + + await waitFor(() => { + expect(vi.mocked(reconnectServer)).toHaveBeenCalled(); + }); + + const [, effectiveConfig] = vi.mocked(reconnectServer).mock.calls[0] ?? []; + expect(effectiveConfig).toMatchObject({ + capabilities: { + experimental: { + workspaceProfile: {}, + }, + sampling: {}, + elicitation: {}, + }, + clientCapabilities: { + experimental: { + workspaceProfile: {}, + }, + sampling: {}, + elicitation: {}, + }, + }); + }); }); diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index 9df9b4d3c..15777fd3c 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -218,6 +218,7 @@ export function useServerState({ return { ...serverConfig, capabilities: mergedCapabilities, + clientCapabilities: mergedCapabilities, }; }, [activeWorkspace?.clientConfig], diff --git a/sdk/src/mcp-client-manager/MCPClientManager.ts b/sdk/src/mcp-client-manager/MCPClientManager.ts index 459f8fed2..ff596f350 100644 --- a/sdk/src/mcp-client-manager/MCPClientManager.ts +++ b/sdk/src/mcp-client-manager/MCPClientManager.ts @@ -90,7 +90,11 @@ import { convertMCPToolsToVercelTools, type ToolSchemaOverrides, } from "./tool-converters.js"; -import { mergeClientCapabilities } from "./capabilities.js"; +import { + getDefaultClientCapabilities, + mergeClientCapabilities, + normalizeClientCapabilities, +} from "./capabilities.js"; /** * Manages multiple MCP server connections with support for tools, resources, @@ -147,7 +151,10 @@ export class MCPClientManager { this.defaultClientVersion = options.defaultClientVersion ?? DEFAULT_CLIENT_VERSION; this.defaultClientName = options.defaultClientName; - this.defaultCapabilities = { ...(options.defaultCapabilities ?? {}) }; + this.defaultCapabilities = mergeClientCapabilities( + getDefaultClientCapabilities(), + options.defaultCapabilities, + ); this.defaultTimeout = options.defaultTimeout ?? DEFAULT_TIMEOUT; this.defaultLogJsonRpc = options.defaultLogJsonRpc ?? false; this.defaultRpcLogger = options.rpcLogger; @@ -1255,6 +1262,10 @@ export class MCPClientManager { } private buildCapabilities(config: MCPServerConfig): ClientCapabilityOptions { + if (config.clientCapabilities) { + return normalizeClientCapabilities(config.clientCapabilities); + } + return mergeClientCapabilities(this.defaultCapabilities, config.capabilities); } diff --git a/sdk/src/mcp-client-manager/capabilities.ts b/sdk/src/mcp-client-manager/capabilities.ts index 2fcbbabe9..9797221c1 100644 --- a/sdk/src/mcp-client-manager/capabilities.ts +++ b/sdk/src/mcp-client-manager/capabilities.ts @@ -67,24 +67,26 @@ export function mergeClientCapabilities( base?: ClientCapabilityOptions, overrides?: ClientCapabilityOptions, ): ClientCapabilityOptions { + const baseRecord = base as Record | undefined; + const overrideRecord = overrides as Record | undefined; const merged: Record = { ...(base ?? {}), ...(overrides ?? {}), }; if ( - overrides && - Object.prototype.hasOwnProperty.call(overrides, "extensions") + overrideRecord && + Object.prototype.hasOwnProperty.call(overrideRecord, "extensions") ) { - const overExt = overrides.extensions; + const overExt = overrideRecord.extensions; if (overExt === undefined) { - merged.extensions = base?.extensions; + merged.extensions = baseRecord?.extensions; } else if (isPlainObject(overExt)) { if (Object.keys(overExt).length === 0) { merged.extensions = {}; - } else if (isPlainObject(base?.extensions)) { + } else if (isPlainObject(baseRecord?.extensions)) { merged.extensions = mergeExtensionsMaps( - base!.extensions as Record, + baseRecord.extensions as Record, overExt, ); } else { diff --git a/sdk/src/mcp-client-manager/types.ts b/sdk/src/mcp-client-manager/types.ts index 49cdb785c..b24612c76 100644 --- a/sdk/src/mcp-client-manager/types.ts +++ b/sdk/src/mcp-client-manager/types.ts @@ -34,8 +34,13 @@ export type ClientCapabilityOptions = NonNullable< * Base configuration shared by all server types */ export type BaseServerConfig = { - /** Client capabilities to advertise to this server */ + /** Legacy merge-style client capabilities to advertise to this server */ capabilities?: ClientCapabilityOptions; + /** + * Exact client capabilities to advertise to this server. + * When provided, this bypasses manager defaults and legacy capability merging. + */ + clientCapabilities?: ClientCapabilityOptions; /** Request timeout in milliseconds */ timeout?: number; /** Client version to report */ diff --git a/sdk/tests/MCPClientManager.test.ts b/sdk/tests/MCPClientManager.test.ts index cf22bbf3f..938d87cf2 100644 --- a/sdk/tests/MCPClientManager.test.ts +++ b/sdk/tests/MCPClientManager.test.ts @@ -1,5 +1,4 @@ import { MCPClientManager } from "../src/mcp-client-manager"; -import { getDefaultClientCapabilities } from "../src/mcp-client-manager/capabilities"; import { startMockHttpServer, startMockStreamableHttpServer, @@ -287,7 +286,7 @@ describe("MCPClientManager", () => { expect(capabilities?.tools).toBeDefined(); }, 30000); - it("should not advertise MCP Apps UI extension by default", async () => { + it("should advertise MCP Apps UI extension by default", async () => { await manager.connectToServer("extensions-test", { command: "npx", args: ["-y", "@modelcontextprotocol/server-everything"], @@ -298,20 +297,32 @@ describe("MCPClientManager", () => { const extensions = (info!.clientCapabilities as Record) .extensions as Record; - expect(extensions).toBeUndefined(); + expect(extensions["io.modelcontextprotocol/ui"]).toEqual({ + mimeTypes: ["text/html;profile=mcp-app"], + }); await manager.disconnectServer("extensions-test"); }, 30000); - it("should advertise provided MCP Apps UI extension in client capabilities", async () => { - await manager.connectToServer("extensions-test", { + it("should merge legacy per-server capabilities on top of default UI capabilities", async () => { + await manager.connectToServer("legacy-caps-test", { command: "npx", args: ["-y", "@modelcontextprotocol/server-everything"], - capabilities: getDefaultClientCapabilities(), + capabilities: { + experimental: { + inspectorProfile: {}, + }, + } as any, }); - const info = manager.getInitializationInfo("extensions-test"); + const info = manager.getInitializationInfo("legacy-caps-test"); expect(info).toBeDefined(); + expect(info!.clientCapabilities).toMatchObject({ + experimental: { + inspectorProfile: {}, + }, + elicitation: {}, + }); const extensions = (info!.clientCapabilities as Record) .extensions as Record; @@ -319,16 +330,65 @@ describe("MCPClientManager", () => { mimeTypes: ["text/html;profile=mcp-app"], }); - await manager.disconnectServer("extensions-test"); + await manager.disconnectServer("legacy-caps-test"); }, 30000); - it("should preserve custom capabilities without injecting the UI extension", async () => { + it("should merge manager defaultCapabilities with legacy per-server capabilities", async () => { + const managerWithDefaults = new MCPClientManager( + {}, + { + defaultCapabilities: { + sampling: {}, + } as any, + }, + ); + + try { + await managerWithDefaults.connectToServer("manager-default-caps-test", { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-everything"], + capabilities: { + experimental: { + inspectorProfile: {}, + }, + } as any, + }); + + const info = managerWithDefaults.getInitializationInfo( + "manager-default-caps-test", + ); + expect(info).toBeDefined(); + expect(info!.clientCapabilities).toMatchObject({ + sampling: {}, + experimental: { + inspectorProfile: {}, + }, + elicitation: {}, + }); + expect( + ((info!.clientCapabilities as Record).extensions as + | Record + | undefined)?.["io.modelcontextprotocol/ui"], + ).toEqual({ + mimeTypes: ["text/html;profile=mcp-app"], + }); + } finally { + await managerWithDefaults.disconnectAllServers(); + } + }, 30000); + + it("should let per-server clientCapabilities bypass defaults and legacy merge", async () => { await manager.connectToServer("custom-caps-test", { command: "npx", args: ["-y", "@modelcontextprotocol/server-everything"], capabilities: { experimental: { - inspectorProfile: {}, + legacyPath: {}, + }, + } as any, + clientCapabilities: { + experimental: { + exactPath: {}, }, } as any, }); @@ -337,12 +397,19 @@ describe("MCPClientManager", () => { expect(info).toBeDefined(); expect(info!.clientCapabilities).toMatchObject({ experimental: { - inspectorProfile: {}, + exactPath: {}, }, elicitation: {}, }); + expect(info!.clientCapabilities).not.toMatchObject({ + experimental: { + legacyPath: {}, + }, + }); expect( - (info!.clientCapabilities as Record).extensions, + ((info!.clientCapabilities as Record).extensions as + | Record + | undefined)?.["io.modelcontextprotocol/ui"], ).toBeUndefined(); await manager.disconnectServer("custom-caps-test"); From 5b7fcfa4907d4490382deef98c41f9aae725af65 Mon Sep 17 00:00:00 2001 From: chelojimenez <58269507+chelojimenez@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:34:36 +0000 Subject: [PATCH 13/14] style: auto-fix prettier formatting --- .../src/components/chat-v2/thread/chatgpt-app-renderer.tsx | 4 +++- .../components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx | 3 ++- .../client/src/hooks/__tests__/use-server-state.test.tsx | 5 ++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx index 1b022006b..77b8a63b9 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx @@ -768,7 +768,9 @@ export function ChatGPTAppRenderer({ () => extractHostSafeAreaInsets( draftHostContext, - isPlaygroundActive ? playgroundSafeAreaInsets : DEFAULT_SAFE_AREA_INSETS, + isPlaygroundActive + ? playgroundSafeAreaInsets + : DEFAULT_SAFE_AREA_INSETS, ), [draftHostContext, isPlaygroundActive, playgroundSafeAreaInsets], ); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx index 8e2912c74..2898d7094 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx @@ -724,7 +724,8 @@ export function MCPAppsRenderer({ baseHostContext.styles && typeof baseHostContext.styles === "object" && !Array.isArray(baseHostContext.styles) && - Object.keys(baseHostContext.styles as Record).length > 0 + Object.keys(baseHostContext.styles as Record).length > + 0 ? (baseHostContext.styles as McpUiHostContext["styles"]) : { variables: styleVariables, diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx index e60c1325f..75e78d6d6 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx @@ -245,9 +245,8 @@ describe("useServerState OAuth callback failures", () => { it("passes exact workspace-derived clientCapabilities on local reconnect", async () => { const { reconnectServer } = await import("@/state/mcp-api"); - const { ensureAuthorizedForReconnect } = await import( - "@/state/oauth-orchestrator" - ); + const { ensureAuthorizedForReconnect } = + await import("@/state/oauth-orchestrator"); vi.mocked(reconnectServer).mockResolvedValue({ success: true, initInfo: { From 053f03fc248236ce75cbc8d00a98ca6047daecac Mon Sep 17 00:00:00 2001 From: marcelo Date: Tue, 24 Mar 2026 10:53:04 -0700 Subject: [PATCH 14/14] prettier --- .../src/components/chat-v2/thread/chatgpt-app-renderer.tsx | 4 +++- .../components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx | 3 ++- .../client/src/hooks/__tests__/use-server-state.test.tsx | 5 ++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx index 1b022006b..77b8a63b9 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx @@ -768,7 +768,9 @@ export function ChatGPTAppRenderer({ () => extractHostSafeAreaInsets( draftHostContext, - isPlaygroundActive ? playgroundSafeAreaInsets : DEFAULT_SAFE_AREA_INSETS, + isPlaygroundActive + ? playgroundSafeAreaInsets + : DEFAULT_SAFE_AREA_INSETS, ), [draftHostContext, isPlaygroundActive, playgroundSafeAreaInsets], ); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx index 8e2912c74..2898d7094 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx @@ -724,7 +724,8 @@ export function MCPAppsRenderer({ baseHostContext.styles && typeof baseHostContext.styles === "object" && !Array.isArray(baseHostContext.styles) && - Object.keys(baseHostContext.styles as Record).length > 0 + Object.keys(baseHostContext.styles as Record).length > + 0 ? (baseHostContext.styles as McpUiHostContext["styles"]) : { variables: styleVariables, diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx index e60c1325f..75e78d6d6 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx @@ -245,9 +245,8 @@ describe("useServerState OAuth callback failures", () => { it("passes exact workspace-derived clientCapabilities on local reconnect", async () => { const { reconnectServer } = await import("@/state/mcp-api"); - const { ensureAuthorizedForReconnect } = await import( - "@/state/oauth-orchestrator" - ); + const { ensureAuthorizedForReconnect } = + await import("@/state/oauth-orchestrator"); vi.mocked(reconnectServer).mockResolvedValue({ success: true, initInfo: {