Add server selector to basic-host example (#95)

* Add server selector to basic-host example

- Add ServerSelector component to switch between MCP servers
- Run all example servers concurrently on ports 3101-3107
- Update examples:start to launch all servers with assigned ports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Move server selector into tool call panel

- Server selector now in same component as tool selector
- Tool call results persist across server switches
- Connection caching avoids reconnects when switching servers
- Display server name with tool name in results (ServerName:ToolName)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Olivier Chafik 2025-12-08 18:45:50 +01:00 • committed by GitHub
commit f6ebefe238
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 137 additions and 48 deletions

View file

@ -1,3 +1,9 @@
.connecting {
padding: 1rem 0;
text-align: center;
color: #666;
}
.callToolPanel, .toolCallInfoPanel {
margin: 0 auto;
padding: 1rem;
@ -10,6 +16,9 @@
}
.callToolPanel {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 480px;
form {

View file

@ -4,35 +4,113 @@ import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy,
import styles from "./index.module.css";
const MCP_SERVER_URL = new URL("http://localhost:3001/mcp");
// Available MCP servers - using ports 3101+ to avoid conflicts with common dev ports
const SERVERS = [
{ name: "Basic React", port: 3101 },
{ name: "Vanilla JS", port: 3102 },
{ name: "Budget Allocator", port: 3103 },
{ name: "Cohort Heatmap", port: 3104 },
{ name: "Customer Segmentation", port: 3105 },
{ name: "Scenario Modeler", port: 3106 },
{ name: "System Monitor", port: 3107 },
] as const;
interface HostProps {
serverInfoPromise: Promise<ServerInfo>;
function serverUrl(port: number): string {
return `http://localhost:${port}/mcp`;
}
function Host({ serverInfoPromise }: HostProps) {
const serverInfo = use(serverInfoPromise);
const [toolCallInfos, setToolCallInfos] = useState<ToolCallInfo[]>([]);
// Cache server connections to avoid reconnecting when switching between servers
const serverInfoCache = new Map<number, Promise<ServerInfo>>();
function getServerInfo(port: number): Promise<ServerInfo> {
let promise = serverInfoCache.get(port);
if (!promise) {
promise = connectToServer(new URL(serverUrl(port)));
// Remove from cache on failure so retry is possible
promise.catch(() => serverInfoCache.delete(port));
serverInfoCache.set(port, promise);
}
return promise;
}
// Wrapper to track server name with each tool call
interface ToolCallEntry {
serverName: string;
info: ToolCallInfo;
}
// Host just manages tool call results - no server dependency
function Host() {
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
return (
<>
{toolCallInfos.map((info, i) => (
<ToolCallInfoPanel key={i} toolCallInfo={info} />
{toolCalls.map((entry, i) => (
<ToolCallInfoPanel key={i} serverName={entry.serverName} toolCallInfo={entry.info} />
))}
<CallToolPanel
serverInfo={serverInfo}
addToolCallInfo={(info) => setToolCallInfos([...toolCallInfos, info])}
addToolCall={(serverName, info) => setToolCalls([...toolCalls, { serverName, info }])}
/>
</>
);
}
// CallToolPanel includes server selection with its own Suspense boundary
interface CallToolPanelProps {
serverInfo: ServerInfo;
addToolCallInfo: (toolCallInfo: ToolCallInfo) => void;
addToolCall: (serverName: string, info: ToolCallInfo) => void;
}
function CallToolPanel({ serverInfo, addToolCallInfo }: CallToolPanelProps) {
function CallToolPanel({ addToolCall }: CallToolPanelProps) {
const [selectedServer, setSelectedServer] = useState(SERVERS[0]);
const [serverInfoPromise, setServerInfoPromise] = useState(
() => getServerInfo(selectedServer.port)
);
const handleServerChange = (port: number) => {
const server = SERVERS.find(s => s.port === port) ?? SERVERS[0];
setSelectedServer(server);
setServerInfoPromise(getServerInfo(port));
};
return (
<div className={styles.callToolPanel}>
<label>
Server
<select
value={selectedServer.port}
onChange={(e) => handleServerChange(Number(e.target.value))}
>
{SERVERS.map(({ name, port }) => (
<option key={port} value={port}>
{name} (:{port})
</option>
))}
</select>
</label>
<ErrorBoundary>
<Suspense fallback={<p className={styles.connecting}>Connecting to {serverUrl(selectedServer.port)}...</p>}>
<ToolCallForm
key={selectedServer.port}
serverName={selectedServer.name}
serverInfoPromise={serverInfoPromise}
addToolCall={addToolCall}
/>
</Suspense>
</ErrorBoundary>
</div>
);
}
// ToolCallForm renders inside Suspense - needs serverInfo for tool list
interface ToolCallFormProps {
serverName: string;
serverInfoPromise: Promise<ServerInfo>;
addToolCall: (serverName: string, info: ToolCallInfo) => void;
}
function ToolCallForm({ serverName, serverInfoPromise, addToolCall }: ToolCallFormProps) {
const serverInfo = use(serverInfoPromise);
const toolNames = Array.from(serverInfo.tools.keys());
const [selectedTool, setSelectedTool] = useState(toolNames[0] ?? "");
const [inputJson, setInputJson] = useState("{}");
@ -48,48 +126,47 @@ function CallToolPanel({ serverInfo, addToolCallInfo }: CallToolPanelProps) {
const handleSubmit = () => {
const toolCallInfo = callTool(serverInfo, selectedTool, JSON.parse(inputJson));
addToolCallInfo(toolCallInfo);
addToolCall(serverName, toolCallInfo);
};
return (
<div className={styles.callToolPanel}>
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<label>
Tool Name
<select
value={selectedTool}
onChange={(e) => setSelectedTool(e.target.value)}
>
{toolNames.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</label>
<label>
Tool Input
<textarea
aria-invalid={!isValidJson}
value={inputJson}
onChange={(e) => setInputJson(e.target.value)}
/>
</label>
<button type="submit" disabled={!selectedTool || !isValidJson}>
Call Tool
</button>
</form>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<label>
Tool
<select
value={selectedTool}
onChange={(e) => setSelectedTool(e.target.value)}
>
{toolNames.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</label>
<label>
Input
<textarea
aria-invalid={!isValidJson}
value={inputJson}
onChange={(e) => setInputJson(e.target.value)}
/>
</label>
<button type="submit" disabled={!selectedTool || !isValidJson}>
Call Tool
</button>
</form>
);
}
interface ToolCallInfoPanelProps {
serverName: string;
toolCallInfo: ToolCallInfo;
}
function ToolCallInfoPanel({ toolCallInfo }: ToolCallInfoPanelProps) {
function ToolCallInfoPanel({ serverName, toolCallInfo }: ToolCallInfoPanelProps) {
return (
<div className={styles.toolCallInfoPanel}>
<div className={styles.inputInfoPanel}>
<h2 className={styles.toolName}>{toolCallInfo.tool.name}</h2>
<h2 className={styles.toolName}>{serverName}:{toolCallInfo.tool.name}</h2>
<JsonBlock value={toolCallInfo.input} />
</div>
<div className={styles.outputInfoPanel}>
@ -188,8 +265,6 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Suspense fallback={<p>Connecting to server ({MCP_SERVER_URL.href})...</p>}>
<Host serverInfoPromise={connectToServer(MCP_SERVER_URL)} />
</Suspense>
<Host />
</StrictMode>,
);

View file

@ -30,10 +30,15 @@
"build:all": "npm run build && npm run examples:build",
"test": "bun test",
"examples:build": "find examples -maxdepth 1 -mindepth 1 -type d -exec printf '%s\\0' 'npm run --workspace={} build' ';' | xargs -0 concurrently --kill-others-on-fail",
"examples:start": "NODE_ENV=development npm run build && concurrently 'npm run examples:start:basic-host' 'npm run examples:start:basic-server-react'",
"examples:start": "NODE_ENV=development npm run build && concurrently 'npm run examples:start:basic-host' 'npm run examples:start:basic-server-react' 'npm run examples:start:basic-server-vanillajs' 'npm run examples:start:budget-allocator-server' 'npm run examples:start:cohort-heatmap-server' 'npm run examples:start:customer-segmentation-server' 'npm run examples:start:scenario-modeler-server' 'npm run examples:start:system-monitor-server'",
"examples:start:basic-host": "npm run --workspace=examples/basic-host start",
"examples:start:basic-server-react": "npm run --workspace=examples/basic-server-react start",
"examples:start:basic-server-vanillajs": "npm run --workspace=examples/basic-server-vanillajs start",
"examples:start:basic-server-react": "PORT=3101 npm run --workspace=examples/basic-server-react start",
"examples:start:basic-server-vanillajs": "PORT=3102 npm run --workspace=examples/basic-server-vanillajs start",
"examples:start:budget-allocator-server": "PORT=3103 npm run --workspace=examples/budget-allocator-server start",
"examples:start:cohort-heatmap-server": "PORT=3104 npm run --workspace=examples/cohort-heatmap-server start",
"examples:start:customer-segmentation-server": "PORT=3105 npm run --workspace=examples/customer-segmentation-server start",
"examples:start:scenario-modeler-server": "PORT=3106 npm run --workspace=examples/scenario-modeler-server start",
"examples:start:system-monitor-server": "PORT=3107 npm run --workspace=examples/system-monitor-server start",
"watch": "nodemon --watch src --ext ts,tsx --exec 'bun build.bun.ts'",
"examples:dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run examples:dev:basic-host' 'npm run examples:dev:basic-server-react'",
"examples:dev:basic-host": "npm run --workspace=examples/basic-host dev",