Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ 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

### 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`.

### 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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
211 changes: 211 additions & 0 deletions src/__tests__/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,4 +745,215 @@ 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 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<string, unknown>).method === "ui/initialize",
);
const id = (initCall?.[0] as Record<string, unknown>).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();
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" });
});
});
});
98 changes: 37 additions & 61 deletions src/__tests__/detection.test.ts
Original file line number Diff line number Diff line change
@@ -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>): McpUiInitializeResult {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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");
});
});
Loading