From f8a6606fee6ff8bd65197b47269c3e3334c49a70 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:34:04 -1000 Subject: [PATCH 1/4] Expose host context via useHostContext; theme becomes a selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify ext-apps host context as a single source of truth in `createSynapse`. Previously theme had its own state (`currentTheme`) and callback set (`themeCallbacks`) populated from `ui/notifications/host-context-changed`, which discarded every other field. That meant the SDK had no way to react to non-theme host-context updates — workspace, displayMode, host extensions — even though the spec carries them on the same notification. Now there's one piece of state (`currentHostContext: McpUiHostContext`) and one callback set. `getTheme()` / `onThemeChanged()` are selectors over it, with `themesEqual` filtering no-op fires so existing theme subscribers don't see spurious updates when only workspace changes. New public API: - synapse.getHostContext() / onHostContextChanged() - useHostContext() React hook (typed via `McpUiHostContext`) `useTheme()` is unchanged for callers; internally it's now a one-line useMemo selector over useHostContext. This is the SDK half of the workspace-aware host context plumbing — needed so iframe apps (e.g. NimbleBrain's home briefing) can react to workspace switches without remounting. Bumps to 0.6.0. --- CHANGELOG.md | 10 +++ package.json | 2 +- src/__tests__/core.test.ts | 173 +++++++++++++++++++++++++++++++++++++ src/core.ts | 93 +++++++++++++------- src/detection.ts | 2 +- src/react/hooks.ts | 44 ++++++++-- src/react/index.ts | 1 + src/types.ts | 28 ++++++ 8 files changed, 315 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 675df05..6ae6560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [0.6.0] - 2026-04-24 + +### Added + +- `useHostContext()` React hook and `synapse.getHostContext()` / `synapse.onHostContextChanged()` for reading and observing the full ext-apps host context — including host-specific extensions like NimbleBrain's `workspace` field. Returns the spec-typed `McpUiHostContext`. + +### Changed + +- `getTheme()` / `useTheme()` / `onThemeChanged()` are now selectors over the unified host-context state. Same API and behavior, but `onThemeChanged` no longer fires when only non-theme fields (e.g. workspace) change. + ## [0.5.0] - 2026-04-21 Minor bump: removes a public method from the `Synapse` interface. Also changes the wire format of `synapse/download-file` (now sends a `Blob`, not a string) — must ship paired with a host bridge that accepts a `Blob` payload. diff --git a/package.json b/package.json index 44a5f29..9d92de3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nimblebrain/synapse", - "version": "0.5.0", + "version": "0.6.0", "description": "Agent-aware app SDK for the MCP ext-apps protocol", "type": "module", "exports": { diff --git a/src/__tests__/core.test.ts b/src/__tests__/core.test.ts index 01e1fbd..53b0fa5 100644 --- a/src/__tests__/core.test.ts +++ b/src/__tests__/core.test.ts @@ -745,4 +745,177 @@ describe("createSynapse", () => { // Double destroy should not throw expect(() => synapse.destroy()).not.toThrow(); }); + + // ------------------------------------------------------------------------ + // Host context — single source of truth, theme is a derived selector + // ------------------------------------------------------------------------ + + describe("host context", () => { + it("getHostContext() returns the handshake-provided context after ready", async () => { + synapse = createSynapse({ name: "test-app", version: "1.0.0" }); + completeHandshake(); + await synapse.ready; + + const ctx = synapse.getHostContext(); + expect(ctx.theme).toBe("dark"); + expect(ctx.styles).toEqual({ variables: {} }); + }); + + it("getHostContext() returns {} before handshake completes", () => { + synapse = createSynapse({ name: "test-app", version: "1.0.0" }); + synapse.ready.catch(() => {}); + expect(synapse.getHostContext()).toEqual({}); + }); + + it("onHostContextChanged fires on host-context-changed notifications with full snapshot", async () => { + synapse = createSynapse({ name: "test-app", version: "1.0.0" }); + completeHandshake(); + await synapse.ready; + + const cb = vi.fn(); + synapse.onHostContextChanged(cb); + + dispatchNotification("ui/notifications/host-context-changed", { + theme: "light", + styles: { variables: { "--x": "1" } }, + workspace: { id: "ws_a", name: "Alpha" }, + }); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith({ + theme: "light", + styles: { variables: { "--x": "1" } }, + workspace: { id: "ws_a", name: "Alpha" }, + }); + expect(synapse.getHostContext()).toMatchObject({ + workspace: { id: "ws_a", name: "Alpha" }, + }); + }); + + it("host-context-changed has replace semantics — fields not in the new params are dropped", async () => { + synapse = createSynapse({ name: "test-app", version: "1.0.0" }); + completeHandshake(); + await synapse.ready; + + // First push: workspace present + dispatchNotification("ui/notifications/host-context-changed", { + theme: "dark", + styles: { variables: {} }, + workspace: { id: "ws_a", name: "Alpha" }, + }); + expect(synapse.getHostContext()).toMatchObject({ workspace: { id: "ws_a" } }); + + // Second push: workspace omitted — must NOT linger + dispatchNotification("ui/notifications/host-context-changed", { + theme: "dark", + styles: { variables: {} }, + }); + expect(synapse.getHostContext().workspace).toBeUndefined(); + }); + + it("onHostContextChanged unsubscribe stops further fires", async () => { + synapse = createSynapse({ name: "test-app", version: "1.0.0" }); + completeHandshake(); + await synapse.ready; + + const cb = vi.fn(); + const unsub = synapse.onHostContextChanged(cb); + dispatchNotification("ui/notifications/host-context-changed", { + theme: "dark", + styles: { variables: {} }, + }); + expect(cb).toHaveBeenCalledTimes(1); + + unsub(); + dispatchNotification("ui/notifications/host-context-changed", { + theme: "light", + styles: { variables: {} }, + }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("destroy() clears host-context subscribers", async () => { + synapse = createSynapse({ name: "test-app", version: "1.0.0" }); + completeHandshake(); + await synapse.ready; + + const cb = vi.fn(); + synapse.onHostContextChanged(cb); + synapse.destroy(); + + dispatchNotification("ui/notifications/host-context-changed", { + theme: "light", + }); + expect(cb).not.toHaveBeenCalled(); + }); + + it("getTheme() is derived from host context — handshake reflects in theme.mode", async () => { + synapse = createSynapse({ name: "test-app", version: "1.0.0" }); + completeHandshake(); + await synapse.ready; + expect(synapse.getTheme().mode).toBe("dark"); + }); + + it("onThemeChanged fires when host-context-changed actually moves the theme", async () => { + synapse = createSynapse({ name: "test-app", version: "1.0.0" }); + completeHandshake(); + await synapse.ready; + + const cb = vi.fn(); + synapse.onThemeChanged(cb); + + dispatchNotification("ui/notifications/host-context-changed", { + theme: "light", + styles: { variables: {} }, + }); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0].mode).toBe("light"); + }); + + it("onThemeChanged does NOT fire when only non-theme fields change (e.g. workspace)", async () => { + synapse = createSynapse({ name: "test-app", version: "1.0.0" }); + completeHandshake(); + await synapse.ready; + + const themeCb = vi.fn(); + const ctxCb = vi.fn(); + synapse.onThemeChanged(themeCb); + synapse.onHostContextChanged(ctxCb); + + // Same theme/styles as the handshake, only workspace differs + dispatchNotification("ui/notifications/host-context-changed", { + theme: "dark", + styles: { variables: {} }, + workspace: { id: "ws_a", name: "Alpha" }, + }); + + expect(ctxCb).toHaveBeenCalledTimes(1); + expect(themeCb).not.toHaveBeenCalled(); + + // Switching workspace again must still not fire theme + dispatchNotification("ui/notifications/host-context-changed", { + theme: "dark", + styles: { variables: {} }, + workspace: { id: "ws_b", name: "Beta" }, + }); + expect(ctxCb).toHaveBeenCalledTimes(2); + expect(themeCb).not.toHaveBeenCalled(); + }); + + it("onThemeChanged fires when token values change even if mode is identical", async () => { + synapse = createSynapse({ name: "test-app", version: "1.0.0" }); + completeHandshake(); + await synapse.ready; + + const cb = vi.fn(); + synapse.onThemeChanged(cb); + + dispatchNotification("ui/notifications/host-context-changed", { + theme: "dark", + styles: { variables: { "--color-bg": "#000" } }, + }); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0].tokens).toEqual({ "--color-bg": "#000" }); + }); + }); }); diff --git a/src/core.ts b/src/core.ts index 7744135..b165fea 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,6 +1,7 @@ import type { - McpUiHostContextChangedNotification, + McpUiHostContext, McpUiInitializeRequest, + McpUiInitializeResult, McpUiMessageRequest, McpUiOpenLinkRequest, McpUiUpdateModelContextRequest, @@ -19,7 +20,7 @@ import type { TextContent, } from "@modelcontextprotocol/sdk/types.js"; -import { detectHost } from "./detection.js"; +import { detectHost, extractTheme } from "./detection.js"; import { KeyboardForwarder } from "./keyboard.js"; import { parseToolResult } from "./result-parser.js"; import { SynapseTransport } from "./transport.js"; @@ -55,11 +56,12 @@ export function createSynapse(options: SynapseOptions): Synapse { const transport = new SynapseTransport(); let hostInfo: HostInfo | null = null; - let currentTheme: SynapseTheme = { - mode: "light", - primaryColor: "#6366f1", - tokens: {}, - }; + // Single source of truth for ext-apps host context. `theme`, `styles`, + // `displayMode`, `toolInfo` are spec-standardized fields; the open + // `[key: string]: unknown` lets hosts publish extensions (e.g. NimbleBrain + // populates a `workspace` field). `getTheme()` and any other typed view + // is derived from this object — no parallel state. + let currentHostContext: McpUiHostContext = {}; let destroyed = false; // --- Debounce for setVisibleState --- @@ -79,36 +81,32 @@ export function createSynapse(options: SynapseOptions): Synapse { .request(INITIALIZE_METHOD, initParams as unknown as Record) .then((result) => { hostInfo = detectHost(result); - currentTheme = hostInfo.theme; + currentHostContext = ((result as McpUiInitializeResult | null)?.hostContext ?? + {}) as McpUiHostContext; // Inject host CSS variables into :root so plain-CSS styles can consume // them via `var(--…)` without needing to read theme.tokens imperatively. - injectCssVariables(currentTheme.tokens); + injectCssVariables(extractTheme(currentHostContext).tokens); - // Notify subscribers so React and custom onThemeChanged - // listeners reflect the handshake-provided theme (not just subsequent - // host-context-changed notifications). - for (const cb of themeCallbacks) cb(currentTheme); + // Notify subscribers so React hooks (useTheme, useHostContext) and + // custom listeners reflect the handshake-provided context (not just + // subsequent host-context-changed notifications). + for (const cb of hostContextCallbacks) cb(currentHostContext); transport.send(INITIALIZED_METHOD, {}); keyboard = new KeyboardForwarder(transport, forwardKeys); }); - // Listen for theme changes from the host (ext-apps spec) - const unsubTheme = transport.onMessage(HOST_CONTEXT_CHANGED_METHOD, (params) => { - if (!params) return; - const mode = params.theme === "dark" ? "dark" : "light"; - // Spec: tokens are nested under styles.variables - const styles = params.styles as Record | undefined; - const variables = styles?.variables as Record | undefined; - const tokens = variables && typeof variables === "object" ? variables : currentTheme.tokens; - currentTheme = { mode, primaryColor: currentTheme.primaryColor, tokens }; - injectCssVariables(tokens); - for (const cb of themeCallbacks) cb(currentTheme); + // Listen for host context changes (ext-apps spec). Notifications carry a + // full snapshot of the host context, so we replace — never merge. + const unsubHostContext = transport.onMessage(HOST_CONTEXT_CHANGED_METHOD, (params) => { + currentHostContext = (params ?? {}) as McpUiHostContext; + injectCssVariables(extractTheme(currentHostContext).tokens); + for (const cb of hostContextCallbacks) cb(currentHostContext); }); - const themeCallbacks = new Set<(theme: SynapseTheme) => void>(); + const hostContextCallbacks = new Set<(ctx: McpUiHostContext) => void>(); const dataCallbacks = new Set<(event: DataChangedEvent) => void>(); const actionCallbacks = new Set<(action: AgentAction) => void>(); @@ -189,14 +187,35 @@ export function createSynapse(options: SynapseOptions): Synapse { }; }, + getHostContext(): McpUiHostContext { + return currentHostContext; + }, + + onHostContextChanged(callback: (ctx: McpUiHostContext) => void): () => void { + hostContextCallbacks.add(callback); + return () => { + hostContextCallbacks.delete(callback); + }; + }, + getTheme(): SynapseTheme { - return { ...currentTheme }; + return extractTheme(currentHostContext); }, + // Selector over `onHostContextChanged`: only fires when the *derived* + // theme actually changes, so theme subscribers don't see spurious + // updates when other host-context fields (e.g. workspace) change. onThemeChanged(callback: (theme: SynapseTheme) => void): () => void { - themeCallbacks.add(callback); + let prev = extractTheme(currentHostContext); + const wrapped = (ctx: McpUiHostContext) => { + const next = extractTheme(ctx); + if (themesEqual(prev, next)) return; + prev = next; + callback(next); + }; + hostContextCallbacks.add(wrapped); return () => { - themeCallbacks.delete(callback); + hostContextCallbacks.delete(wrapped); }; }, @@ -299,10 +318,10 @@ export function createSynapse(options: SynapseOptions): Synapse { if (stateTimer) clearTimeout(stateTimer); keyboard?.destroy(); - unsubTheme(); + unsubHostContext(); unsubData(); unsubAction(); - themeCallbacks.clear(); + hostContextCallbacks.clear(); dataCallbacks.clear(); actionCallbacks.clear(); transport.destroy(); @@ -312,6 +331,20 @@ export function createSynapse(options: SynapseOptions): Synapse { return synapse; } +/** Shallow equality for `SynapseTheme` — used by `onThemeChanged` to filter + * host-context changes that don't actually move the theme (e.g. a workspace + * switch that leaves theme/styles untouched). Cheap; tokens are ~40 entries. */ +function themesEqual(a: SynapseTheme, b: SynapseTheme): boolean { + if (a.mode !== b.mode || a.primaryColor !== b.primaryColor) return false; + const aKeys = Object.keys(a.tokens); + const bKeys = Object.keys(b.tokens); + if (aKeys.length !== bKeys.length) return false; + for (const k of aKeys) { + if (a.tokens[k] !== b.tokens[k]) return false; + } + return true; +} + /** Inject CSS custom properties onto :root so widgets inherit host theming. */ function injectCssVariables(vars: Record | undefined | null): void { if (!vars || typeof vars !== "object") return; diff --git a/src/detection.ts b/src/detection.ts index a7c997f..4215b22 100644 --- a/src/detection.ts +++ b/src/detection.ts @@ -29,7 +29,7 @@ export function detectHost(initResponse: unknown): HostInfo { }; } -function extractTheme(ctx: Partial | undefined): SynapseTheme { +export function extractTheme(ctx: Partial | undefined): SynapseTheme { if (!ctx) return { ...DEFAULT_THEME }; // Spec: theme is a string ("light" | "dark") diff --git a/src/react/hooks.ts b/src/react/hooks.ts index 7a08bfa..069d9c0 100644 --- a/src/react/hooks.ts +++ b/src/react/hooks.ts @@ -1,4 +1,6 @@ -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { extractTheme } from "../detection.js"; import type { ActionReducer, AgentAction, @@ -99,16 +101,46 @@ export function useAgentAction(callback: (action: AgentAction) => void): void { }, [synapse]); } -export function useTheme(): SynapseTheme { +/** + * Subscribe to the full ext-apps host context. + * + * Returns the host context bag — spec-standardized fields (`theme`, `styles`, + * `displayMode`, `toolInfo`) plus any host extensions. Re-renders on every + * `ui/notifications/host-context-changed` notification. + * + * Prefer `useTheme()` when only theming matters (it filters no-op fires and + * returns a typed `SynapseTheme`). Reach for `useHostContext()` for non-theme + * fields like host-specific extensions, e.g. on NimbleBrain: + * + * ```tsx + * const { workspace } = useHostContext<{ workspace?: { id: string } }>(); + * ``` + */ +export function useHostContext(): T { const synapse = useSynapseContext(); - const [theme, setTheme] = useState(() => synapse.getTheme()); + const [ctx, setCtx] = useState(() => synapse.getHostContext() as T); useEffect(() => { - // Sync in case theme changed between render and effect - setTheme(synapse.getTheme()); - return synapse.onThemeChanged(setTheme); + // Sync in case context changed between render and effect + setCtx(synapse.getHostContext() as T); + return synapse.onHostContextChanged((c) => setCtx(c as T)); }, [synapse]); + return ctx; +} + +/** + * Typed selector over `useHostContext` for theming. Re-renders only when the + * derived `SynapseTheme` actually changes (mode, primaryColor, or any token + * value), so theme consumers don't see spurious updates when other host + * context fields move. + */ +export function useTheme(): SynapseTheme { + const ctx = useHostContext(); + const theme = useMemo(() => extractTheme(ctx), [ctx]); + // useMemo retains identity within the same `ctx`; an upstream re-render + // with the same `ctx` reference is impossible (state replace, not mutation), + // so structural identity tracks logical identity. return theme; } diff --git a/src/react/index.ts b/src/react/index.ts index b84381d..2755490 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -16,6 +16,7 @@ export { useChat, useDataSync, useFileUpload, + useHostContext, useStore, useSynapse, useTheme, diff --git a/src/types.ts b/src/types.ts index ea5e5c7..0aae15b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,10 @@ +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import type { ReadResourceRequest, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; +// Re-export so SDK consumers can type host-context reads without a separate +// dependency on `@modelcontextprotocol/ext-apps`. +export type { McpUiHostContext }; + // ---------- Core ---------- export interface SynapseOptions { @@ -139,6 +144,29 @@ export interface Synapse { */ onAction(callback: (action: AgentAction) => void): () => void; + /** + * Read the current ext-apps host context as last received from the host. + * + * Spec-standardized fields (`theme`, `styles`, `displayMode`, `toolInfo`) + * are typed; the open `[key: string]: unknown` allows hosts to publish + * extensions (e.g. NimbleBrain populates `workspace`). Apps reading + * host-specific fields should treat them as optional and tolerate + * missing values when running on other hosts. + * + * Returns the empty object before the `ui/initialize` handshake completes. + */ + getHostContext(): McpUiHostContext; + + /** + * Subscribe to host-context updates. Fires once per + * `ui/notifications/host-context-changed` notification (which carries a + * full snapshot, not a delta) and once on handshake completion. + * + * `getTheme`/`onThemeChanged` are typed selectors over this same state — + * prefer them when only theming matters, since they filter no-op fires. + */ + onHostContextChanged(callback: (ctx: McpUiHostContext) => void): () => void; + getTheme(): SynapseTheme; onThemeChanged(callback: (theme: SynapseTheme) => void): () => void; From f38c929aea888d0c3e120e1c74e9253412b5eab5 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:35:14 -1000 Subject: [PATCH 2/4] QA review fixes: useTheme contract + handshake fire regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness issues from review on PR #6: 1. useTheme bypassed the themesEqual filter. Building it on useHostContext meant every host-context-changed produced a new ctx reference, which propagated through useState and forced a re-render even when the derived theme was unchanged. Defeats the whole point of the selector. Route useTheme through synapse.onThemeChanged so React consumers inherit the SDK-level filter. 2. onThemeChanged could miss the handshake fire for subscribers added before synapse.ready resolves. `prev = extractTheme({})` at subscribe time, then on the handshake fire `next = extractTheme(ctx)` — when the host's hostContext derives to the default theme (light, no tokens) the wrapper silently filtered it as a no-op. Use `hostInfo === null` as the discriminator: pre-handshake subscribers seed `prev = null` (first fire always fires); post-handshake subscribers seed `prev = current theme` (workspace-only updates correctly filter). Also: - New `react/host-context-hooks.test.tsx` covers useHostContext re-renders on host-context-changed and useTheme does NOT re-render on workspace-only changes — locks in the fix from #1. - New core.ts test covers the handshake-fire regression from #2. - One-line clarifier on themesEqual explaining why iterating only `a`'s keys is sufficient under equal length. --- src/__tests__/core.test.ts | 38 ++++ .../react/host-context-hooks.test.tsx | 182 ++++++++++++++++++ src/core.ts | 17 +- src/react/hooks.ts | 31 +-- 4 files changed, 255 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/react/host-context-hooks.test.tsx diff --git a/src/__tests__/core.test.ts b/src/__tests__/core.test.ts index 53b0fa5..029a0c8 100644 --- a/src/__tests__/core.test.ts +++ b/src/__tests__/core.test.ts @@ -902,6 +902,44 @@ describe("createSynapse", () => { expect(themeCb).not.toHaveBeenCalled(); }); + it("onThemeChanged fires on handshake even when host theme matches the SDK default", async () => { + // Regression guard: a subscriber added before `synapse.ready` resolves + // must receive the handshake fire, even when the handshake-provided + // theme would derive to the same SynapseTheme as the empty/default + // context the wrapped callback was seeded against. + synapse = createSynapse({ name: "test-app", version: "1.0.0" }); + const cb = vi.fn(); + synapse.onThemeChanged(cb); + + // Hand the host a context whose extracted theme is light/no-tokens — + // structurally identical to extractTheme({}). Pre-fix this would have + // been silently filtered out as a no-op fire. + const initCall = postMessageSpy.mock.calls.find( + (c: unknown[]) => + c[0] && + typeof c[0] === "object" && + (c[0] as Record).method === "ui/initialize", + ); + const id = (initCall?.[0] as Record).id as string; + window.dispatchEvent( + new MessageEvent("message", { + data: { + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2026-01-26", + hostInfo: { name: "nimblebrain", version: "1.0.0" }, + hostCapabilities: {}, + hostContext: { theme: "light", styles: { variables: {} } }, + }, + }, + }), + ); + await synapse.ready; + + expect(cb).toHaveBeenCalledTimes(1); + }); + it("onThemeChanged fires when token values change even if mode is identical", async () => { synapse = createSynapse({ name: "test-app", version: "1.0.0" }); completeHandshake(); diff --git a/src/__tests__/react/host-context-hooks.test.tsx b/src/__tests__/react/host-context-hooks.test.tsx new file mode 100644 index 0000000..e4b49de --- /dev/null +++ b/src/__tests__/react/host-context-hooks.test.tsx @@ -0,0 +1,182 @@ +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SynapseProvider } from "../../react/provider.js"; +import { useHostContext, useTheme } from "../../react/hooks.js"; + +// --- Helpers --- + +let postMessageSpy: ReturnType; + +function makeInitResult(hostContext?: Record) { + return { + protocolVersion: "2026-01-26", + hostInfo: { name: "nimblebrain", version: "1.0.0" }, + hostCapabilities: {}, + hostContext: hostContext ?? { theme: "dark", styles: { variables: {} } }, + }; +} + +function completeHandshake(hostContext?: Record) { + const initCall = postMessageSpy.mock.calls.find( + (c: unknown[]) => + c[0] && + typeof c[0] === "object" && + (c[0] as Record).method === "ui/initialize", + ); + if (!initCall) throw new Error("No ui/initialize call found"); + const id = (initCall[0] as Record).id as string; + window.dispatchEvent( + new MessageEvent("message", { + data: { jsonrpc: "2.0", id, result: makeInitResult(hostContext) }, + }), + ); +} + +function dispatchHostContextChanged(params: Record) { + window.dispatchEvent( + new MessageEvent("message", { + data: { + jsonrpc: "2.0", + method: "ui/notifications/host-context-changed", + params, + }, + }), + ); +} + +function wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +// --- Tests --- + +describe("useHostContext / useTheme React hooks", () => { + beforeEach(() => { + postMessageSpy = vi.fn(); + window.parent.postMessage = postMessageSpy; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("useHostContext", () => { + it("returns the handshake host context after ready", async () => { + const { result } = renderHook(() => useHostContext(), { wrapper }); + await act(async () => { + completeHandshake({ + theme: "dark", + styles: { variables: {} }, + workspace: { id: "ws_a", name: "Alpha" }, + }); + }); + expect(result.current).toMatchObject({ + theme: "dark", + workspace: { id: "ws_a", name: "Alpha" }, + }); + }); + + it("re-renders when host-context-changed fires", async () => { + const renderCount = vi.fn(); + const { result } = renderHook( + () => { + renderCount(); + return useHostContext<{ workspace?: { id: string } }>(); + }, + { wrapper }, + ); + await act(async () => { + completeHandshake({ theme: "dark", styles: { variables: {} } }); + }); + + const before = renderCount.mock.calls.length; + await act(async () => { + dispatchHostContextChanged({ + theme: "dark", + styles: { variables: {} }, + workspace: { id: "ws_b", name: "Beta" }, + }); + }); + + expect(renderCount.mock.calls.length).toBeGreaterThan(before); + expect(result.current.workspace?.id).toBe("ws_b"); + }); + }); + + describe("useTheme", () => { + it("returns the derived theme after handshake", async () => { + const { result } = renderHook(() => useTheme(), { wrapper }); + await act(async () => { + completeHandshake({ + theme: "dark", + styles: { variables: { "--color-bg": "#000" } }, + }); + }); + expect(result.current.mode).toBe("dark"); + expect(result.current.tokens).toEqual({ "--color-bg": "#000" }); + }); + + it("does NOT re-render on workspace-only host-context changes", async () => { + // Contract: useTheme is a filtered selector. A host-context-changed + // notification that leaves theme/styles untouched (e.g. only workspace + // moved) must not cause theme consumers to re-render. + const renderCount = vi.fn(); + renderHook( + () => { + renderCount(); + return useTheme(); + }, + { wrapper }, + ); + await act(async () => { + completeHandshake({ theme: "dark", styles: { variables: {} } }); + }); + + const before = renderCount.mock.calls.length; + await act(async () => { + dispatchHostContextChanged({ + theme: "dark", + styles: { variables: {} }, + workspace: { id: "ws_a", name: "Alpha" }, + }); + dispatchHostContextChanged({ + theme: "dark", + styles: { variables: {} }, + workspace: { id: "ws_b", name: "Beta" }, + }); + }); + + // Allow the initial useEffect-triggered setTheme(getTheme()) to settle + // by comparing render counts before/after the two notifications. Two + // workspace-only notifications must produce zero re-renders. + expect(renderCount.mock.calls.length).toBe(before); + }); + + it("re-renders when the theme actually changes", async () => { + const renderCount = vi.fn(); + const { result } = renderHook( + () => { + renderCount(); + return useTheme(); + }, + { wrapper }, + ); + await act(async () => { + completeHandshake({ theme: "dark", styles: { variables: {} } }); + }); + + const before = renderCount.mock.calls.length; + await act(async () => { + dispatchHostContextChanged({ theme: "light", styles: { variables: {} } }); + }); + + expect(renderCount.mock.calls.length).toBeGreaterThan(before); + expect(result.current.mode).toBe("light"); + }); + }); +}); diff --git a/src/core.ts b/src/core.ts index b165fea..c9b8071 100644 --- a/src/core.ts +++ b/src/core.ts @@ -205,11 +205,21 @@ export function createSynapse(options: SynapseOptions): Synapse { // Selector over `onHostContextChanged`: only fires when the *derived* // theme actually changes, so theme subscribers don't see spurious // updates when other host-context fields (e.g. workspace) change. + // + // Subscriber timing matters: + // - Subscribed BEFORE handshake: `prev = null` sentinel. The first + // fire (the handshake dispatch) always invokes the callback, + // even if the host's theme happens to derive to the default. + // Otherwise consumers using `onThemeChanged` as their init + // signal would silently miss it. + // - Subscribed AFTER handshake: `prev` is pre-seeded with the + // current derived theme, so a workspace-only `host-context-changed` + // notification correctly filters as a no-op. onThemeChanged(callback: (theme: SynapseTheme) => void): () => void { - let prev = extractTheme(currentHostContext); + let prev: SynapseTheme | null = hostInfo !== null ? extractTheme(currentHostContext) : null; const wrapped = (ctx: McpUiHostContext) => { const next = extractTheme(ctx); - if (themesEqual(prev, next)) return; + if (prev !== null && themesEqual(prev, next)) return; prev = next; callback(next); }; @@ -339,6 +349,9 @@ function themesEqual(a: SynapseTheme, b: SynapseTheme): boolean { const aKeys = Object.keys(a.tokens); const bKeys = Object.keys(b.tokens); if (aKeys.length !== bKeys.length) return false; + // Iterating only over `a`'s keys is sufficient: under equal length, any + // key in `a` missing from `b` reads `b[k] === undefined`, which fails the + // strict-inequality check. Symmetric difference is covered. for (const k of aKeys) { if (a.tokens[k] !== b.tokens[k]) return false; } diff --git a/src/react/hooks.ts b/src/react/hooks.ts index 069d9c0..f8fbf60 100644 --- a/src/react/hooks.ts +++ b/src/react/hooks.ts @@ -1,6 +1,5 @@ import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { extractTheme } from "../detection.js"; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import type { ActionReducer, AgentAction, @@ -130,17 +129,27 @@ export function useHostContext(): } /** - * Typed selector over `useHostContext` for theming. Re-renders only when the - * derived `SynapseTheme` actually changes (mode, primaryColor, or any token - * value), so theme consumers don't see spurious updates when other host - * context fields move. + * Re-renders only when the derived `SynapseTheme` actually changes (mode, + * primaryColor, or any token value). Routes through `synapse.onThemeChanged` + * — which is itself a `themesEqual`-filtered selector over the unified host + * context — so a host-context update that doesn't move the theme (e.g. a + * workspace switch) does NOT cause this hook's consumers to re-render. + * + * Building this on `useHostContext` instead would skip the filter: every + * host-context-changed notification produces a new context reference, which + * would propagate through `useState` and force a re-render even when the + * derived theme is unchanged. */ export function useTheme(): SynapseTheme { - const ctx = useHostContext(); - const theme = useMemo(() => extractTheme(ctx), [ctx]); - // useMemo retains identity within the same `ctx`; an upstream re-render - // with the same `ctx` reference is impossible (state replace, not mutation), - // so structural identity tracks logical identity. + const synapse = useSynapseContext(); + const [theme, setTheme] = useState(() => synapse.getTheme()); + + useEffect(() => { + // Sync in case theme changed between render and effect + setTheme(synapse.getTheme()); + return synapse.onThemeChanged(setTheme); + }, [synapse]); + return theme; } From 1e0c01a61e28ab4b8bdce307efac5f22b724bd9e Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:41:46 -1000 Subject: [PATCH 3/4] =?UTF-8?q?Drop=20HostInfo.theme=20=E2=80=94=20dead=20?= =?UTF-8?q?since=20the=20host-context=20unification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-refactor, `detectHost` populated `HostInfo.theme` and `core.ts` read it once on handshake to seed the SDK's `currentTheme`. After unifying state on `currentHostContext`, theme is derived via `extractTheme(currentHostContext)` and `HostInfo.theme` was no longer read anywhere — internally or externally (zero references in the host repo or any bundle). Drop the field rather than leave it as a deprecated remnant: - `HostInfo` reports identity only (host name, protocol version, isNimbleBrain). Theme is host-context state, not host-identity. - `detectHost` becomes leaner — no theme computation, no DEFAULT_THEME closure path. - Theme tests in `detection.test.ts` move to a new `describe("extractTheme")` block that exercises the function directly. They were testing the wrong layer before — `detectHost` was a pass-through to `extractTheme`. --- src/__tests__/detection.test.ts | 98 +++++++++++++-------------------- src/detection.ts | 8 +-- src/types.ts | 1 - 3 files changed, 41 insertions(+), 66 deletions(-) diff --git a/src/__tests__/detection.test.ts b/src/__tests__/detection.test.ts index 18ffd0e..805b423 100644 --- a/src/__tests__/detection.test.ts +++ b/src/__tests__/detection.test.ts @@ -1,6 +1,6 @@ import type { McpUiHostContext, McpUiInitializeResult } from "@modelcontextprotocol/ext-apps"; import { describe, expect, it } from "vitest"; -import { detectHost } from "../detection"; +import { detectHost, extractTheme } from "../detection"; /** Helper to build a spec-compliant init result. */ function makeResult(overrides?: Partial): McpUiInitializeResult { @@ -47,56 +47,12 @@ describe("detectHost", () => { expect(result.serverName).toBe("unknown"); }); - it("extracts theme from hostContext (spec format)", () => { - const result = detectHost( - makeResult({ - hostContext: { - theme: "dark", - styles: { - variables: { "--color-background-primary": "#111" }, - }, - }, - }), - ); - - expect(result.theme.mode).toBe("dark"); - expect(result.theme.tokens).toEqual({ "--color-background-primary": "#111" }); - }); - - it("falls back to default theme when hostContext is empty", () => { - const result = detectHost( - makeResult({ - hostInfo: { name: "claude", version: "1.0.0" }, - hostContext: {} as McpUiHostContext, - }), - ); - - expect(result.theme.mode).toBe("light"); - expect(result.theme.primaryColor).toBe("#6366f1"); - expect(result.theme.tokens).toEqual({}); - }); - - it("falls back to empty tokens when styles.variables is missing", () => { - const result = detectHost( - makeResult({ - hostContext: { - theme: "dark", - } as McpUiHostContext, - }), - ); - - expect(result.theme.mode).toBe("dark"); - expect(result.theme.primaryColor).toBe("#6366f1"); - expect(result.theme.tokens).toEqual({}); - }); - it("handles null input safely", () => { const result = detectHost(null); expect(result.isNimbleBrain).toBe(false); expect(result.serverName).toBe("unknown"); expect(result.protocolVersion).toBe("unknown"); - expect(result.theme.mode).toBe("light"); }); it("handles undefined input safely", () => { @@ -105,27 +61,47 @@ describe("detectHost", () => { expect(result.isNimbleBrain).toBe(false); expect(result.serverName).toBe("unknown"); }); +}); - it("handles hostContext with no theme key", () => { - const result = detectHost( - makeResult({ - hostContext: {} as McpUiHostContext, - }), - ); +describe("extractTheme", () => { + it("extracts theme from a spec-shaped hostContext", () => { + const theme = extractTheme({ + theme: "dark", + styles: { + variables: { "--color-background-primary": "#111" }, + }, + }); - expect(result.theme.mode).toBe("light"); - expect(result.theme.primaryColor).toBe("#6366f1"); + expect(theme.mode).toBe("dark"); + expect(theme.tokens).toEqual({ "--color-background-primary": "#111" }); + }); + + it("falls back to the default theme when hostContext is undefined", () => { + const theme = extractTheme(undefined); + + expect(theme.mode).toBe("light"); + expect(theme.primaryColor).toBe("#6366f1"); + expect(theme.tokens).toEqual({}); + }); + + it("falls back to empty tokens when styles.variables is missing", () => { + const theme = extractTheme({ theme: "dark" } as McpUiHostContext); + + expect(theme.mode).toBe("dark"); + expect(theme.primaryColor).toBe("#6366f1"); + expect(theme.tokens).toEqual({}); + }); + + it("uses default mode when hostContext has no theme key", () => { + const theme = extractTheme({} as McpUiHostContext); + + expect(theme.mode).toBe("light"); + expect(theme.primaryColor).toBe("#6366f1"); }); it("ignores invalid theme mode values", () => { - const result = detectHost( - makeResult({ - hostContext: { - theme: "sepia" as any, - } as McpUiHostContext, - }), - ); + const theme = extractTheme({ theme: "sepia" as unknown as "light" }); - expect(result.theme.mode).toBe("light"); + expect(theme.mode).toBe("light"); }); }); diff --git a/src/detection.ts b/src/detection.ts index 4215b22..d30c6b5 100644 --- a/src/detection.ts +++ b/src/detection.ts @@ -10,7 +10,10 @@ const DEFAULT_THEME: SynapseTheme = { /** * Detect the host environment from the ext-apps `ui/initialize` response. * - * Uses spec field names (hostInfo, hostContext.theme as string, styles.variables). + * Reports identity only (host name, protocol version). Theme lives in the + * unified host-context state and is read via `extractTheme(hostContext)` + * — no parallel `theme` field on `HostInfo`. + * * Handles missing or malformed fields gracefully — never throws. */ export function detectHost(initResponse: unknown): HostInfo { @@ -18,14 +21,11 @@ export function detectHost(initResponse: unknown): HostInfo { const hostName = resp?.hostInfo?.name ?? "unknown"; const protocolVersion = resp?.protocolVersion ?? "unknown"; - const ctx = resp?.hostContext as Partial | undefined; - const theme = extractTheme(ctx); return { isNimbleBrain: hostName === "nimblebrain", serverName: hostName, protocolVersion, - theme, }; } diff --git a/src/types.ts b/src/types.ts index 0aae15b..5265d76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -319,7 +319,6 @@ export interface HostInfo { isNimbleBrain: boolean; serverName: string; protocolVersion: string; - theme: SynapseTheme; } // ---------- Connect API ---------- From 9f36d272049f499bad34ba1e907d59308e8559e5 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:51:49 -1000 Subject: [PATCH 4/4] QA review fixes: lint imports + CHANGELOG breaking note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sort imports in host-context-hooks.test.tsx so `bun run ci` (which runs `bun run lint`) passes the assist/source/organizeImports rule. Confirmed by the existing CI script in package.json. - Call out HostInfo.theme removal under a new ### Breaking section in the 0.6.0 CHANGELOG entry. HostInfo is publicly exported, so dropping a field from it is a breaking change for any external consumer reading hostInfo.theme — even at pre-1.0. --- CHANGELOG.md | 4 ++++ src/__tests__/react/host-context-hooks.test.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ae6560..ef2f9df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [0.6.0] - 2026-04-24 +### Breaking + +- `HostInfo` no longer carries a `theme` field. It was redundant after the host-context unification; read theme via `synapse.getTheme()` / `useTheme()` instead. `HostInfo` reports identity only (host name, protocol version, `isNimbleBrain`). + ### Added - `useHostContext()` React hook and `synapse.getHostContext()` / `synapse.onHostContextChanged()` for reading and observing the full ext-apps host context — including host-specific extensions like NimbleBrain's `workspace` field. Returns the spec-typed `McpUiHostContext`. diff --git a/src/__tests__/react/host-context-hooks.test.tsx b/src/__tests__/react/host-context-hooks.test.tsx index e4b49de..e41ce6f 100644 --- a/src/__tests__/react/host-context-hooks.test.tsx +++ b/src/__tests__/react/host-context-hooks.test.tsx @@ -1,8 +1,8 @@ import { act, renderHook } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SynapseProvider } from "../../react/provider.js"; import { useHostContext, useTheme } from "../../react/hooks.js"; +import { SynapseProvider } from "../../react/provider.js"; // --- Helpers ---