Add dynamic example orchestration script

Replace hardcoded port assignments and npm scripts with run-all.ts that:
- Auto-discovers example directories and assigns ports
- Passes server URLs to basic-host via SERVERS env var
- Supports start, dev, and build commands

Update basic-host to fetch server list from /api/servers endpoint
and display server names from MCP server metadata.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2025-12-08 13:56:10 -06:00
commit 8bff1dff68
7 changed files with 180 additions and 87 deletions

View file

@ -18,6 +18,7 @@ const __dirname = dirname(__filename);
const HOST_PORT = parseInt(process.env.HOST_PORT || "8080", 10);
const SANDBOX_PORT = parseInt(process.env.SANDBOX_PORT || "8081", 10);
const DIRECTORY = join(__dirname, "dist");
const SERVERS: string[] = process.env.SERVERS ? JSON.parse(process.env.SERVERS) : [];
// ============ Host Server (port 8080) ============
const hostApp = express();
@ -34,6 +35,11 @@ hostApp.use((req, res, next) => {
hostApp.use(express.static(DIRECTORY));
// API endpoint to get configured server URLs
hostApp.get("/api/servers", (_req, res) => {
res.json(SERVERS);
});
hostApp.get("/", (_req, res) => {
res.redirect("/index.html");
});
@ -70,7 +76,7 @@ sandboxApp.use((_req, res) => {
});
// ============ Start both servers ============
hostApp.listen(HOST_PORT, err => {
hostApp.listen(HOST_PORT, (err) => {
if (err) {
console.error("Error starting server:", err);
process.exit(1);
@ -78,7 +84,7 @@ hostApp.listen(HOST_PORT, err => {
console.log(`Host server: http://localhost:${HOST_PORT}`);
});
sandboxApp.listen(SANDBOX_PORT, err => {
sandboxApp.listen(SANDBOX_PORT, (err) => {
if (err) {
console.error("Error starting server:", err);
process.exit(1);

View file

@ -17,6 +17,7 @@ export const log = {
export interface ServerInfo {
name: string;
client: Client;
tools: Map<string, Tool>;
appHtmlCache: Map<string, string>;
@ -30,11 +31,13 @@ export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
await client.connect(new StreamableHTTPClientTransport(serverUrl));
log.info("Connection successful");
const name = client.getServerVersion()?.name ?? serverUrl.href;
const toolsList = await client.listTools();
const tools = new Map(toolsList.tools.map((tool) => [tool.name, tool]));
log.info("Server tools:", Array.from(tools.keys()));
return { client, tools, appHtmlCache: new Map() };
return { name, client, tools, appHtmlCache: new Map() };
}

View file

@ -4,53 +4,31 @@ import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy,
import styles from "./index.module.css";
// 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 },
{ name: "Three.js", port: 3109 },
] as const;
function serverUrl(port: number): string {
return `http://localhost:${port}/mcp`;
}
// 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() {
// Host receives connected servers via promise, uses single use() call
interface HostProps {
serversPromise: Promise<ServerInfo[]>;
}
function Host({ serversPromise }: HostProps) {
const servers = use(serversPromise);
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
if (servers.length === 0) {
return <p>No servers configured. Set SERVERS environment variable.</p>;
}
return (
<>
{toolCalls.map((entry, i) => (
<ToolCallInfoPanel key={i} serverName={entry.serverName} toolCallInfo={entry.info} />
))}
<CallToolPanel
servers={servers}
addToolCall={(serverName, info) => setToolCalls([...toolCalls, { serverName, info }])}
/>
</>
@ -58,60 +36,48 @@ function Host() {
}
// CallToolPanel includes server selection with its own Suspense boundary
// CallToolPanel manages server selection from already-connected servers
interface CallToolPanelProps {
servers: ServerInfo[];
addToolCall: (serverName: string, info: ToolCallInfo) => void;
}
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));
};
function CallToolPanel({ servers, addToolCall }: CallToolPanelProps) {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedServer = servers[selectedIndex];
return (
<div className={styles.callToolPanel}>
<label>
Server
<select
value={selectedServer.port}
onChange={(e) => handleServerChange(Number(e.target.value))}
value={selectedIndex}
onChange={(e) => setSelectedIndex(Number(e.target.value))}
>
{SERVERS.map(({ name, port }) => (
<option key={port} value={port}>
{name} (:{port})
{servers.map((server, i) => (
<option key={i} value={i}>
{server.name}
</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>
<ToolCallForm
key={selectedIndex}
serverName={selectedServer.name}
serverInfo={selectedServer}
addToolCall={addToolCall}
/>
</div>
);
}
// ToolCallForm renders inside Suspense - needs serverInfo for tool list
// ToolCallForm receives already-resolved serverInfo
interface ToolCallFormProps {
serverName: string;
serverInfoPromise: Promise<ServerInfo>;
serverInfo: ServerInfo;
addToolCall: (serverName: string, info: ToolCallInfo) => void;
}
function ToolCallForm({ serverName, serverInfoPromise, addToolCall }: ToolCallFormProps) {
const serverInfo = use(serverInfoPromise);
function ToolCallForm({ serverName, serverInfo, addToolCall }: ToolCallFormProps) {
const toolNames = Array.from(serverInfo.tools.keys());
const [selectedTool, setSelectedTool] = useState(toolNames[0] ?? "");
const [inputJson, setInputJson] = useState("{}");
@ -264,8 +230,18 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
}
async function connectToAllServers(): Promise<ServerInfo[]> {
const serverUrlsResponse = await fetch("/api/servers");
const serverUrls = (await serverUrlsResponse.json()) as string[];
return Promise.all(serverUrls.map((url) => connectToServer(new URL(url))));
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Host />
<ErrorBoundary>
<Suspense fallback={<p className={styles.connecting}>Connecting to servers...</p>}>
<Host serversPromise={connectToAllServers()} />
</Suspense>
</ErrorBoundary>
</StrictMode>,
);

View file

@ -12,7 +12,7 @@ const DIST_DIR = path.join(import.meta.dirname, "dist");
const server = new McpServer({
name: "MCP App Server",
name: "Basic MCP App Server (React-based)",
version: "1.0.0",
});
@ -85,7 +85,7 @@ app.post("/mcp", async (req: Request, res: Response) => {
}
});
const httpServer = app.listen(PORT, err => {
const httpServer = app.listen(PORT, (err) => {
if (err) {
console.error("Error starting server:", err);
process.exit(1);

View file

@ -12,7 +12,7 @@ const DIST_DIR = path.join(import.meta.dirname, "dist");
const server = new McpServer({
name: "MCP App Server",
name: "Basic MCP App Server (Vanilla JS)",
version: "1.0.0",
});
@ -85,7 +85,7 @@ app.post("/mcp", async (req: Request, res: Response) => {
}
});
const httpServer = app.listen(PORT, err => {
const httpServer = app.listen(PORT, (err) => {
if (err) {
console.error("Error starting server:", err);
process.exit(1);

120
examples/run-all.ts Normal file
View file

@ -0,0 +1,120 @@
#!/usr/bin/env bun
/**
* Orchestration script for running all example servers.
*
* Usage:
* bun examples/run-all.ts start - Build and start all examples
* bun examples/run-all.ts dev - Run all examples in dev/watch mode
* bun examples/run-all.ts build - Build all examples
*/
import { readdirSync, statSync, existsSync } from "fs";
import { spawn, type ChildProcess } from "child_process";
const BASE_PORT = 3101;
const BASIC_HOST = "basic-host";
// Find all example directories except basic-host that have a package.json,
// assign ports, and build URL list
const servers = readdirSync("examples")
.filter(
(d) =>
d !== BASIC_HOST &&
statSync(`examples/${d}`).isDirectory() &&
existsSync(`examples/${d}/package.json`),
)
.sort() // Sort for consistent port assignment
.map((dir, i) => ({
dir,
port: BASE_PORT + i,
url: `http://localhost:${BASE_PORT + i}/mcp`,
}));
const COMMANDS = ["start", "dev", "build"];
const command = process.argv[2];
if (!command || !COMMANDS.includes(command)) {
console.error(`Usage: bun examples/run-all.ts <${COMMANDS.join("|")}>`);
process.exit(1);
}
const processes: ChildProcess[] = [];
// Handle cleanup on exit
function cleanup() {
for (const proc of processes) {
proc.kill();
}
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// Spawn a process and track it
function spawnProcess(
cmd: string,
args: string[],
env: Record<string, string> = {},
prefix: string,
): ChildProcess {
const proc = spawn(cmd, args, {
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
proc.stdout?.on("data", (data) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
console.log(`[${prefix}] ${line}`);
}
});
proc.stderr?.on("data", (data) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
console.error(`[${prefix}] ${line}`);
}
});
proc.on("exit", (code) => {
if (code !== 0 && code !== null) {
console.error(`[${prefix}] exited with code ${code}`);
}
});
processes.push(proc);
return proc;
}
// Build the SERVERS environment variable (JSON array of URLs)
const serversEnv = JSON.stringify(servers.map((s) => s.url));
console.log(`Running command: ${command}`);
console.log(
`Server examples: ${servers.map((s) => `${s.dir}:${s.port}`).join(", ")}`,
);
console.log("");
// If dev mode, also run the main library watcher
if (command === "dev") {
spawnProcess("npm", ["run", "watch"], {}, "lib");
}
// Run each server example
for (const { dir, port } of servers) {
spawnProcess(
"npm",
["run", "--workspace", `examples/${dir}`, command],
{ PORT: String(port) },
dir,
);
}
// Run basic-host with the SERVERS env var
spawnProcess(
"npm",
["run", "--workspace", `examples/${BASIC_HOST}`, command],
{ SERVERS: serversEnv },
BASIC_HOST,
);

View file

@ -31,22 +31,10 @@
"prepack": "npm run build",
"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' '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' 'npm run examples:start:threejs-server'",
"examples:start:basic-host": "npm run --workspace=examples/basic-host 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",
"examples:start:threejs-server": "PORT=3109 npm run --workspace=examples/threejs-server start",
"examples:build": "bun examples/run-all.ts build",
"examples:start": "NODE_ENV=development npm run build && bun examples/run-all.ts start",
"examples:dev": "NODE_ENV=development bun examples/run-all.ts dev",
"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",
"examples:dev:basic-server-react": "npm run --workspace=examples/basic-server-react dev",
"examples:dev:basic-server-vanillajs": "npm run --workspace=examples/basic-server-vanillajs dev",
"prepare": "npm run build && husky",
"docs": "typedoc",
"docs:watch": "typedoc --watch",