Skip to content

Commit 7bbf2bb

Browse files
authored
feat(basic-host): Add theme toggle and MCP style variables (#336)
* feat(basic-host): add theme toggle and MCP style variables Add theme support to basic-host example: - theme.ts: Global theme manager with light/dark toggle - host-styles.ts: Full set of MCP CSS variables using light-dark() - ThemeToggle component with sun/moon icons - Theme-aware CSS using CSS custom properties - Pass theme + styles to apps via hostContext - Notify apps when theme changes via sendHostContextChange Apps now receive: - hostContext.theme: 'light' | 'dark' - hostContext.styles.variables: All 60+ MCP CSS variables - onhostcontextchanged notifications when theme changes * test: update e2e goldens for theme changes
1 parent ee5d3f8 commit 7bbf2bb

10 files changed

Lines changed: 287 additions & 12 deletions

File tree

examples/basic-host/sandbox.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
margin: 0;
1313
height: 100vh;
1414
width: 100vw;
15+
/* Transparent background allows parent page to show through */
16+
background-color: transparent;
1517
}
1618
body {
1719
display: flex;
@@ -26,6 +28,8 @@
2628
padding: 0px;
2729
overflow: hidden;
2830
flex-grow: 1;
31+
/* Inherit color scheme from parent for consistent transparency */
32+
color-scheme: inherit;
2933
}
3034
</style>
3135
</head>

examples/basic-host/src/global.css

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,38 @@
1+
:root {
2+
color-scheme: light dark;
3+
4+
/* Light theme (default) */
5+
--color-bg: #ffffff;
6+
--color-bg-secondary: #f5f5f5;
7+
--color-text: #1f2937;
8+
--color-text-secondary: #6b7280;
9+
--color-border: #e5e7eb;
10+
--color-primary: #1e3a5f;
11+
--color-primary-hover: #2d4a7c;
12+
}
13+
14+
[data-theme="dark"] {
15+
--color-bg: #1a1a1a;
16+
--color-bg-secondary: #2d2d2d;
17+
--color-text: #f3f4f6;
18+
--color-text-secondary: #9ca3af;
19+
--color-border: #404040;
20+
--color-primary: #3b82f6;
21+
--color-primary-hover: #60a5fa;
22+
}
23+
124
* {
225
box-sizing: border-box;
326
}
427

528
html, body {
29+
margin: 0;
30+
padding: 0;
631
font-family: system-ui, -apple-system, sans-serif;
732
font-size: 1rem;
33+
background-color: var(--color-bg);
34+
color: var(--color-text);
35+
transition: background-color 0.2s, color 0.2s;
836
}
937

1038
code {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* MCP style variables for the basic-host example.
3+
* These are passed to apps via hostContext.styles.variables.
4+
*/
5+
import type { McpUiStyles } from "@modelcontextprotocol/ext-apps";
6+
7+
/**
8+
* MCP App style variables using light-dark() for theme adaptation.
9+
* Apps receive these and can use them as CSS custom properties.
10+
*/
11+
export const HOST_STYLE_VARIABLES: McpUiStyles = {
12+
// Background colors - using light-dark() for automatic adaptation
13+
"--color-background-primary": "light-dark(#ffffff, #1a1a1a)",
14+
"--color-background-secondary": "light-dark(#f5f5f5, #2d2d2d)",
15+
"--color-background-tertiary": "light-dark(#e5e5e5, #404040)",
16+
"--color-background-inverse": "light-dark(#1a1a1a, #ffffff)",
17+
"--color-background-ghost": "light-dark(rgba(255,255,255,0), rgba(26,26,26,0))",
18+
"--color-background-info": "light-dark(#eff6ff, #1e3a5f)",
19+
"--color-background-danger": "light-dark(#fef2f2, #7f1d1d)",
20+
"--color-background-success": "light-dark(#f0fdf4, #14532d)",
21+
"--color-background-warning": "light-dark(#fefce8, #713f12)",
22+
"--color-background-disabled": "light-dark(rgba(255,255,255,0.5), rgba(26,26,26,0.5))",
23+
24+
// Text colors
25+
"--color-text-primary": "light-dark(#1f2937, #f3f4f6)",
26+
"--color-text-secondary": "light-dark(#6b7280, #9ca3af)",
27+
"--color-text-tertiary": "light-dark(#9ca3af, #6b7280)",
28+
"--color-text-inverse": "light-dark(#f3f4f6, #1f2937)",
29+
"--color-text-ghost": "light-dark(rgba(107,114,128,0.5), rgba(156,163,175,0.5))",
30+
"--color-text-info": "light-dark(#1d4ed8, #60a5fa)",
31+
"--color-text-danger": "light-dark(#b91c1c, #f87171)",
32+
"--color-text-success": "light-dark(#15803d, #4ade80)",
33+
"--color-text-warning": "light-dark(#a16207, #fbbf24)",
34+
"--color-text-disabled": "light-dark(rgba(31,41,55,0.5), rgba(243,244,246,0.5))",
35+
36+
// Border colors
37+
"--color-border-primary": "light-dark(#e5e7eb, #404040)",
38+
"--color-border-secondary": "light-dark(#d1d5db, #525252)",
39+
"--color-border-tertiary": "light-dark(#f3f4f6, #374151)",
40+
"--color-border-inverse": "light-dark(rgba(255,255,255,0.3), rgba(0,0,0,0.3))",
41+
"--color-border-ghost": "light-dark(rgba(229,231,235,0), rgba(64,64,64,0))",
42+
"--color-border-info": "light-dark(#93c5fd, #1e40af)",
43+
"--color-border-danger": "light-dark(#fca5a5, #991b1b)",
44+
"--color-border-success": "light-dark(#86efac, #166534)",
45+
"--color-border-warning": "light-dark(#fde047, #854d0e)",
46+
"--color-border-disabled": "light-dark(rgba(229,231,235,0.5), rgba(64,64,64,0.5))",
47+
48+
// Ring colors (focus)
49+
"--color-ring-primary": "light-dark(#3b82f6, #60a5fa)",
50+
"--color-ring-secondary": "light-dark(#6b7280, #9ca3af)",
51+
"--color-ring-inverse": "light-dark(#ffffff, #1f2937)",
52+
"--color-ring-info": "light-dark(#2563eb, #3b82f6)",
53+
"--color-ring-danger": "light-dark(#dc2626, #ef4444)",
54+
"--color-ring-success": "light-dark(#16a34a, #22c55e)",
55+
"--color-ring-warning": "light-dark(#ca8a04, #eab308)",
56+
57+
// Typography - Family
58+
"--font-sans": "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
59+
"--font-mono": "ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', monospace",
60+
61+
// Typography - Weight
62+
"--font-weight-normal": "400",
63+
"--font-weight-medium": "500",
64+
"--font-weight-semibold": "600",
65+
"--font-weight-bold": "700",
66+
67+
// Typography - Text Size
68+
"--font-text-xs-size": "0.75rem",
69+
"--font-text-sm-size": "0.875rem",
70+
"--font-text-md-size": "1rem",
71+
"--font-text-lg-size": "1.125rem",
72+
73+
// Typography - Heading Size
74+
"--font-heading-xs-size": "0.75rem",
75+
"--font-heading-sm-size": "0.875rem",
76+
"--font-heading-md-size": "1rem",
77+
"--font-heading-lg-size": "1.25rem",
78+
"--font-heading-xl-size": "1.5rem",
79+
"--font-heading-2xl-size": "1.875rem",
80+
"--font-heading-3xl-size": "2.25rem",
81+
82+
// Typography - Text Line Height
83+
"--font-text-xs-line-height": "1.4",
84+
"--font-text-sm-line-height": "1.4",
85+
"--font-text-md-line-height": "1.5",
86+
"--font-text-lg-line-height": "1.5",
87+
88+
// Typography - Heading Line Height
89+
"--font-heading-xs-line-height": "1.4",
90+
"--font-heading-sm-line-height": "1.4",
91+
"--font-heading-md-line-height": "1.4",
92+
"--font-heading-lg-line-height": "1.3",
93+
"--font-heading-xl-line-height": "1.25",
94+
"--font-heading-2xl-line-height": "1.2",
95+
"--font-heading-3xl-line-height": "1.1",
96+
97+
// Border radius
98+
"--border-radius-xs": "2px",
99+
"--border-radius-sm": "4px",
100+
"--border-radius-md": "6px",
101+
"--border-radius-lg": "8px",
102+
"--border-radius-xl": "12px",
103+
"--border-radius-full": "9999px",
104+
105+
// Border width
106+
"--border-width-regular": "1px",
107+
108+
// Shadows
109+
"--shadow-hairline": "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
110+
"--shadow-sm": "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)",
111+
"--shadow-md": "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)",
112+
"--shadow-lg": "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)",
113+
};

examples/basic-host/src/implementation.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
44
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
55
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
6+
import { getTheme, onThemeChange } from "./theme";
7+
import { HOST_STYLE_VARIABLES } from "./host-styles";
68

79

810
const SANDBOX_PROXY_BASE_URL = "http://localhost:8081/sandbox.html";
@@ -270,13 +272,25 @@ export function newAppBridge(
270272
// Declare support for model context updates
271273
updateModelContext: { text: {} },
272274
}, {
275+
// Pass initial host context with theme, display mode, and style variables
273276
hostContext: {
277+
theme: getTheme(),
278+
platform: "web",
279+
styles: {
280+
variables: HOST_STYLE_VARIABLES,
281+
},
274282
containerDimensions: options?.containerDimensions ?? { maxHeight: 6000 },
275283
displayMode: options?.displayMode ?? "inline",
276284
availableDisplayModes: ["inline", "fullscreen"],
277285
},
278286
});
279287

288+
// Listen for theme changes (from toggle or system) and notify the app
289+
onThemeChange((newTheme) => {
290+
log.info("Theme changed:", newTheme);
291+
appBridge.sendHostContextChange({ theme: newTheme });
292+
});
293+
280294
// Register all handlers before calling connect(). The view can start
281295
// sending requests immediately after the initialization handshake, so any
282296
// handlers registered after connect() might miss early requests.

examples/basic-host/src/index.module.css

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,34 @@
1+
/* Theme toggle button */
2+
.themeToggle {
3+
position: fixed;
4+
top: 1rem;
5+
right: 1rem;
6+
width: 2.5rem;
7+
height: 2.5rem;
8+
padding: 0;
9+
border: 1px solid var(--color-border);
10+
border-radius: 50%;
11+
background-color: var(--color-bg-secondary);
12+
font-size: 1.25rem;
13+
cursor: pointer;
14+
z-index: 1000;
15+
transition: background-color 0.2s, border-color 0.2s, transform 0.1s;
16+
17+
&:hover {
18+
background-color: var(--color-border);
19+
}
20+
21+
&:active {
22+
transform: scale(0.95);
23+
}
24+
}
25+
126
.callToolPanel, .toolCallInfoPanel {
227
margin: 0 auto;
328
padding: 1rem;
4-
border: 1px solid #ddd;
29+
border: 1px solid var(--color-border);
530
border-radius: 4px;
31+
background-color: var(--color-bg-secondary);
632

733
* + & {
834
margin-top: 1rem;
@@ -28,9 +54,11 @@
2854
select,
2955
textarea {
3056
padding: 0.5rem;
31-
border: 1px solid #ccc;
57+
border: 1px solid var(--color-border);
3258
border-radius: 4px;
3359
font-size: inherit;
60+
background-color: var(--color-bg);
61+
color: var(--color-text);
3462
}
3563

3664
.toolSelect {
@@ -43,7 +71,7 @@
4371
resize: vertical;
4472

4573
&[aria-invalid="true"] {
46-
background-color: #fdd;
74+
background-color: light-dark(#fee2e2, #7f1d1d);
4775
}
4876
}
4977

@@ -53,14 +81,14 @@
5381
padding: 0.75rem 1.5rem;
5482
border: none;
5583
border-radius: 4px;
56-
background-color: #1e3a5f;
84+
background-color: var(--color-primary);
5785
font-size: inherit;
5886
font-weight: 600;
5987
color: white;
6088
cursor: pointer;
6189

6290
&:hover:not(:disabled) {
63-
background-color: #2d4a7c;
91+
background-color: var(--color-primary-hover);
6492
}
6593

6694
&:disabled {
@@ -103,15 +131,15 @@
103131
padding: 0;
104132
border: none;
105133
border-radius: 4px;
106-
background: #e0e0e0;
134+
background: var(--color-border);
107135
font-size: 1.25rem;
108136
line-height: 1;
109-
color: #666;
137+
color: var(--color-text-secondary);
110138
cursor: pointer;
111139

112140
&:hover {
113-
background: #d0d0d0;
114-
color: #333;
141+
background: var(--color-bg-secondary);
142+
color: var(--color-text);
115143
}
116144
}
117145
}
@@ -123,7 +151,7 @@
123151
width: 100%;
124152
height: 600px;
125153
box-sizing: border-box;
126-
border: 3px dashed #888;
154+
border: 3px dashed var(--color-border);
127155
border-radius: 4px;
128156
}
129157

@@ -246,6 +274,6 @@
246274

247275
.error {
248276
padding: 1.5rem;
249-
background-color: #ddd;
250-
color: #d00;
277+
background-color: var(--color-bg-secondary);
278+
color: light-dark(#dc2626, #f87171);
251279
}

examples/basic-host/src/index.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
33
import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react";
44
import { createRoot } from "react-dom/client";
55
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation";
6+
import { getTheme, toggleTheme, onThemeChange, type Theme } from "./theme";
67
import styles from "./index.module.css";
78

89
/**
@@ -64,6 +65,28 @@ function getQueryParams() {
6465
};
6566
}
6667

68+
/**
69+
* Theme toggle button with light/dark icons.
70+
*/
71+
function ThemeToggle() {
72+
const [theme, setTheme] = useState<Theme>(getTheme);
73+
74+
useEffect(() => {
75+
return onThemeChange(setTheme);
76+
}, []);
77+
78+
return (
79+
<button
80+
className={styles.themeToggle}
81+
onClick={() => toggleTheme()}
82+
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
83+
>
84+
{theme === "dark" ? "☀️" : "🌙"}
85+
</button>
86+
);
87+
}
88+
89+
6790
function Host({ serversPromise }: HostProps) {
6891
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
6992
const [destroyingIds, setDestroyingIds] = useState<Set<number>>(new Set());
@@ -84,6 +107,7 @@ function Host({ serversPromise }: HostProps) {
84107

85108
return (
86109
<>
110+
<ThemeToggle />
87111
{toolCalls.map((info) => (
88112
<ToolCallInfoPanel
89113
key={info.id}

0 commit comments

Comments
 (0)