# Directory Structure ``` ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── config.json ├── docker-compose.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── src │ ├── config │ │ ├── loader.rs │ │ ├── mod.rs │ │ └── model.rs │ ├── constants.rs │ ├── lib.rs │ ├── lib.rs.bak │ ├── main.rs │ ├── process │ │ ├── io.rs │ │ ├── manager.rs │ │ └── mod.rs │ ├── shutdown.rs │ ├── state.rs │ └── websocket │ ├── message.rs │ └── mod.rs └── test.html ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` /target .env .DS_Store ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # MCP Server Runner > **Note**: This project is currently under active development and in WIP (Work In Progress) status. Features and APIs may change significantly. A WebSocket server implementation for running [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP) servers. This application enables MCP servers to be accessed via WebSocket connections, facilitating integration with web applications and other network-enabled clients. ## Development Status - 🚧 **Work In Progress**: This software is in active development - ⚠️ **API Stability**: APIs and features may change without notice - 🧪 **Testing**: Currently undergoing testing and refinement - 📝 **Documentation**: Documentation is being actively updated ## Overview MCP Server Runner acts as a bridge between WebSocket clients and MCP server implementations. It: - Launches an MCP server process - Manages WebSocket connections - Handles bidirectional communication between clients and the MCP server - Supports graceful shutdown and error handling ## Features - WebSocket server implementation with single-client support - Process management for MCP server instances - Bidirectional message passing between client and server - Graceful shutdown handling - Comprehensive error logging - Cross-platform support (Unix/Windows) ## Prerequisites - Rust 1.70 or higher - An MCP server implementation executable ## Configuration ### Environment Variables The application can be configured through environment variables: ```env PROGRAM= # Path to the MCP server executable (required if no config file) ARGS= # Comma-separated list of arguments for the MCP server HOST=0.0.0.0 # Host address to bind to (default: 0.0.0.0) PORT=8080 # Port to listen on (default: 8080) CONFIG_FILE= # Path to JSON configuration file ``` Additional environment variables will be passed through to the MCP server process. ### JSON Configuration Alternatively, you can provide a JSON configuration file: ```json { "servers": { "filesystem": { "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-filesystem", "/path/to/workspace" ] }, "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "your_token_here" } } }, "default_server": "filesystem", "host": "0.0.0.0", "port": 8080 } ``` You can specify the configuration file in two ways: 1. As a command-line argument: `mcp-server-runner config.json` 2. Using the `CONFIG_FILE` environment variable: `CONFIG_FILE=config.json mcp-server-runner` The JSON configuration allows you to define multiple server configurations and select one as the default. ### Configuration Priority 1. Command-line specified config file 2. `CONFIG_FILE` environment variable 3. Environment variables (`PROGRAM`, `ARGS`, etc.) 4. Default values ## Usage 1. Using environment variables: ```bash export PROGRAM=npx export ARGS=-y,@modelcontextprotocol/server-github export PORT=8080 export GITHUB_PERSONAL_ACCESS_TOKEN=github_pat_*** cargo run ``` 2. Using a configuration file: ```bash # Either specify the config file as an argument cargo run config.json # Or use the CONFIG_FILE environment variable CONFIG_FILE=config.json cargo run ``` 3. Connect to the WebSocket server: ```javascript const ws = new WebSocket("ws://localhost:8080"); ``` ## Docker Support A Dockerfile and docker-compose.yml are provided for containerized deployment: ```bash docker-compose up --build ``` ## Development Build the project: ```bash cargo build ``` Run tests: ```bash cargo test ``` Run with debug logging: ```bash RUST_LOG=debug cargo run ``` ## Architecture The application follows a modular architecture: - `main.rs`: Application entry point and server setup - `config/`: Configuration loading and management - `process/`: Process management and I/O handling - `websocket/`: WebSocket connection management - `state.rs`: Global state management - `shutdown.rs`: Graceful shutdown handling ## Error Handling - Standard error output from the MCP server is logged but not forwarded to clients - WebSocket connection errors are handled gracefully - Process errors are logged with detailed information ## Limitations - Supports only one client connection at a time - Does not support WebSocket SSL/TLS (use a reverse proxy for secure connections) - No built-in authentication mechanism ## Contributing 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 3. Commit your changes (`git commit -m 'Add amazing feature'`) 4. Push to the branch (`git push origin feature/amazing-feature`) 5. Open a Pull Request ## License This project is licensed under the MIT License - see the LICENSE file for details. ## Additional Resources - [Model Context Protocol Specification](https://github.com/modelcontextprotocol/specification) - [WebSocket Protocol (RFC 6455)](https://tools.ietf.org/html/rfc6455) ``` -------------------------------------------------------------------------------- /src/process/mod.rs: -------------------------------------------------------------------------------- ```rust mod io; mod manager; pub use manager::ProcessManager; ``` -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- ```rust pub mod model; mod loader; pub use loader::load_config; ``` -------------------------------------------------------------------------------- /src/constants.rs: -------------------------------------------------------------------------------- ```rust /// Size of the message buffer for communication channels. /// This value affects the capacity of mpsc channels used for /// process and WebSocket communication. pub const MESSAGE_BUFFER_SIZE: usize = 100; ``` -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- ```json { "servers": { "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "your_token_here" } } }, "default_server": "github", "host": "0.0.0.0", "port": 8080 } ``` -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- ```rust pub mod config; mod constants; mod process; mod shutdown; mod state; mod websocket; // Re-export public API pub use constants::MESSAGE_BUFFER_SIZE; pub use process::ProcessManager; pub use shutdown::shutdown_signal; pub use websocket::handle_connection; pub use state::{CONNECTED, SHUTDOWN}; ``` -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- ```toml [package] name = "mcp-server-runner" version = "0.1.0" edition = "2021" [dependencies] tokio = { version = "1.0", features = ["full"] } tokio-tungstenite = "0.20" futures-util = "0.3" log = "0.4" env_logger = "0.10" anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" ``` -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- ```yaml version: "3.9" services: mcp-server: build: context: . dockerfile: Dockerfile ports: - "3000:8080" environment: - PROGRAM=${PROGRAM} - ARGS=${ARGS} - GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN} - RUST_LOG=debug volumes: - ./data:/app/data restart: always ``` -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- ```rust use std::sync::atomic::AtomicBool; /// Indicates whether a client is currently connected to the WebSocket server. /// Used to ensure only one client can be connected at a time. pub static CONNECTED: AtomicBool = AtomicBool::new(false); /// Global shutdown flag to signal all components to terminate. /// When set to true, all async tasks should gracefully shut down. pub static SHUTDOWN: AtomicBool = AtomicBool::new(false); ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile FROM rust:latest AS chef RUN cargo install cargo-chef WORKDIR /app FROM chef AS planner COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder COPY --from=planner /app/recipe.json recipe.json RUN cargo chef cook --release --recipe-path recipe.json COPY . . RUN cargo build --release FROM debian:bookworm-slim AS runtime WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ nodejs npm python3 python3-pip curl && \ curl -LsSf https://astral.sh/uv/install.sh | sh && \ apt-get clean && rm -rf /var/lib/apt/lists/* /root/.npm /root/.cache COPY --from=builder /app/target/release/mcp-server-runner /usr/local/bin/ CMD ["mcp-server-runner"] ``` -------------------------------------------------------------------------------- /src/shutdown.rs: -------------------------------------------------------------------------------- ```rust use log::info; use tokio::signal; use std::sync::atomic::Ordering; use crate::state::SHUTDOWN; /// Handles shutdown signals for the application. /// Listens for Ctrl+C and termination signals (on Unix systems), /// and sets the global shutdown flag when received. pub async fn shutdown_signal() { wait_for_shutdown_signal().await; initiate_shutdown(); } /// Waits for either Ctrl+C or termination signal. async fn wait_for_shutdown_signal() { let ctrl_c = setup_ctrl_c(); let terminate = setup_terminate(); tokio::select! { _ = ctrl_c => info!("Ctrl+C received"), _ = terminate => info!("Termination signal received"), } } /// Sets up Ctrl+C signal handler. async fn setup_ctrl_c() { signal::ctrl_c() .await .expect("Failed to install Ctrl+C handler"); } /// Sets up termination signal handler (Unix only). #[cfg(unix)] async fn setup_terminate() { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("Failed to install signal handler") .recv() .await; } /// Placeholder for non-Unix systems. #[cfg(not(unix))] async fn setup_terminate() { std::future::pending::<()>().await } /// Initiates the shutdown process by setting the global shutdown flag. fn initiate_shutdown() { info!("Initiating shutdown sequence"); SHUTDOWN.store(true, Ordering::SeqCst); } ``` -------------------------------------------------------------------------------- /src/websocket/mod.rs: -------------------------------------------------------------------------------- ```rust mod message; use anyhow::Result; use log::info; use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio_tungstenite::accept_async; use std::sync::atomic::Ordering; use futures_util::StreamExt; use crate::state::CONNECTED; use self::message::{handle_incoming_messages, handle_outgoing_messages}; /// Handle a new WebSocket connection pub async fn handle_connection( stream: TcpStream, process_tx: mpsc::Sender<String>, ws_rx: mpsc::Receiver<String>, ) -> Result<()> { let addr = setup_connection(&stream)?; let ws_stream = accept_async(stream).await?; info!("WebSocket connection established: {}", addr); let (ws_writer, ws_reader) = ws_stream.split(); let ws_to_process = handle_incoming_messages(ws_reader, process_tx); let process_to_ws = handle_outgoing_messages(ws_writer, ws_rx); tokio::select! { _ = ws_to_process => info!("WebSocket -> Process handling completed"), _ = process_to_ws => info!("Process -> WebSocket handling completed"), } cleanup_connection(addr); Ok(()) } /// Set up initial connection state fn setup_connection(stream: &TcpStream) -> Result<std::net::SocketAddr> { let addr = stream.peer_addr()?; CONNECTED.store(true, Ordering::SeqCst); Ok(addr) } /// Clean up connection state fn cleanup_connection(addr: std::net::SocketAddr) { CONNECTED.store(false, Ordering::SeqCst); info!("Client disconnected: {}", addr); } ``` -------------------------------------------------------------------------------- /src/websocket/message.rs: -------------------------------------------------------------------------------- ```rust use std::sync::atomic::Ordering; use futures_util::{SinkExt, StreamExt}; use log::{debug, error}; use tokio::sync::mpsc; use tokio_tungstenite::tungstenite::protocol::Message; use futures_util::sink::Sink; use crate::state::SHUTDOWN; pub async fn handle_incoming_messages<S>( mut reader: S, process_tx: mpsc::Sender<String>, ) where S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin, { while let Some(msg) = reader.next().await { if SHUTDOWN.load(Ordering::SeqCst) { break; } match process_incoming_message(msg, &process_tx).await { Ok(should_break) => { if should_break { break; } } Err(e) => { error!("Error processing incoming message: {}", e); break; } } } } pub async fn handle_outgoing_messages<S>( mut writer: S, mut ws_rx: mpsc::Receiver<String>, ) where S: Sink<Message> + Unpin, S::Error: std::fmt::Debug, { while let Some(msg) = ws_rx.recv().await { if SHUTDOWN.load(Ordering::SeqCst) { break; } debug!("Sending process response: {}", msg); if let Err(e) = writer.send(Message::Text(msg)).await { error!("Error sending to WebSocket: {:?}", e); break; } } } async fn process_incoming_message( msg: Result<Message, tokio_tungstenite::tungstenite::Error>, process_tx: &mpsc::Sender<String>, ) -> Result<bool, Box<dyn std::error::Error>> { match msg { Ok(msg) => { if msg.is_close() { return Ok(true); } if let Ok(text) = msg.into_text() { debug!("Received from client: {}", text); process_tx.send(text).await?; } } Err(e) => { error!("Error receiving from WebSocket: {}", e); return Ok(true); } } Ok(false) } ``` -------------------------------------------------------------------------------- /src/process/manager.rs: -------------------------------------------------------------------------------- ```rust use anyhow::{Context, Result}; use log::{debug, error}; use std::collections::HashMap; use tokio::process::{Child, Command}; use tokio::sync::mpsc; use super::io::{handle_stdin, handle_stdout, handle_stderr}; use crate::constants::MESSAGE_BUFFER_SIZE; pub struct ProcessManager { child: Option<Child>, } impl ProcessManager { pub fn new() -> Self { Self { child: None } } pub async fn start_process( &mut self, program: &str, args: &[String], env_vars: &HashMap<String, String>, websocket_tx: mpsc::Sender<String>, ) -> Result<mpsc::Sender<String>> { let child = self.spawn_process(program, args, env_vars)?; let (process_tx, process_rx) = mpsc::channel::<String>(MESSAGE_BUFFER_SIZE); self.setup_io_handlers(child, process_rx, websocket_tx)?; Ok(process_tx) } fn spawn_process( &mut self, program: &str, args: &[String], env_vars: &HashMap<String, String>, ) -> Result<Child> { let mut command = Command::new(program); if !args.is_empty() { command.args(args); } for (key, value) in env_vars { command.env(key, value); } debug!("Spawning process: {} {:?}", program, args); let child = command .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn()?; Ok(child) } fn setup_io_handlers( &mut self, mut child: Child, process_rx: mpsc::Receiver<String>, websocket_tx: mpsc::Sender<String>, ) -> Result<()> { let stdin = child.stdin.take().context("Failed to get child stdin")?; let stdout = child.stdout.take().context("Failed to get child stdout")?; let stderr = child.stderr.take().context("Failed to get child stderr")?; self.child = Some(child); tokio::spawn(handle_stdin(stdin, process_rx)); tokio::spawn(handle_stdout(stdout, websocket_tx)); tokio::spawn(handle_stderr(stderr)); Ok(()) } pub async fn shutdown(&mut self) { if let Some(mut child) = self.child.take() { debug!("Stopping child process..."); if let Err(e) = child.kill().await { error!("Failed to stop child process: {}", e); } if let Err(e) = child.wait().await { error!("Error waiting for child process to exit: {}", e); } debug!("Child process stopped"); } } } ``` -------------------------------------------------------------------------------- /src/process/io.rs: -------------------------------------------------------------------------------- ```rust use log::{debug, error, info, warn}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; use tokio::process::{ChildStdin, ChildStdout, ChildStderr}; use tokio::sync::mpsc; use std::sync::atomic::Ordering; use crate::state::SHUTDOWN; pub async fn handle_stdin( stdin: ChildStdin, mut process_rx: mpsc::Receiver<String>, ) { let mut writer = BufWriter::new(stdin); debug!("Started stdin handler for child process"); while let Some(message) = process_rx.recv().await { if SHUTDOWN.load(Ordering::SeqCst) { debug!("Shutdown signal received, stopping stdin handler"); break; } debug!("Received message to send to process. Length: {}", message.len()); if let Err(e) = write_to_process(&mut writer, &message).await { error!("Error in stdin handling: {}. Message was: {}", e, message); break; } debug!("Successfully wrote message to process"); } info!("Stdin handler finished"); } pub async fn handle_stdout( stdout: ChildStdout, websocket_tx: mpsc::Sender<String>, ) { let mut reader = BufReader::new(stdout); let mut line = String::new(); debug!("Started stdout handler for child process"); while let Ok(n) = reader.read_line(&mut line).await { if should_stop(n) { debug!("Stopping stdout handler: {}", if n == 0 { "EOF reached" } else { "shutdown requested" }); break; } let trimmed = line.trim().to_string(); debug!("Received from process (stdout) - Length: {}, Content: {}", trimmed.len(), trimmed); if let Err(e) = websocket_tx.send(trimmed).await { error!("Error sending to WebSocket: {}", e); break; } debug!("Successfully sent process output to WebSocket"); line.clear(); } info!("Stdout handler finished"); } pub async fn handle_stderr(stderr: ChildStderr) { let mut reader = BufReader::new(stderr); let mut line = String::new(); debug!("Started stderr handler for child process"); while let Ok(n) = reader.read_line(&mut line).await { if should_stop(n) { debug!("Stopping stderr handler: {}", if n == 0 { "EOF reached" } else { "shutdown requested" }); break; } let trimmed = line.trim(); warn!("Process stderr: {}", trimmed); line.clear(); } info!("Stderr handler finished"); } async fn write_to_process( writer: &mut BufWriter<ChildStdin>, message: &str, ) -> tokio::io::Result<()> { debug!("Writing to process - Length: {}, Content: {}", message.len(), message); writer.write_all(message.as_bytes()).await?; writer.write_all(b"\n").await?; writer.flush().await?; debug!("Successfully flushed message to process"); Ok(()) } fn should_stop(n: usize) -> bool { n == 0 || SHUTDOWN.load(Ordering::SeqCst) } ``` -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- ```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>MCP WebSocket Test Client</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/json-formatter/0.7.2/json-formatter.min.js"></script> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; background: #f5f5f5; } .container { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .status { padding: 10px; margin-bottom: 20px; border-radius: 4px; } .status.connected { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .status.disconnected { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .message-box { margin: 20px 0; } .message-input { width: 100%; height: 100px; margin: 10px 0; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-family: monospace; } .button { background-color: #007bff; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; } .button:hover { background-color: #0056b3; } .button:disabled { background-color: #ccc; cursor: not-allowed; } .log { margin-top: 20px; border: 1px solid #ddd; padding: 10px; border-radius: 4px; background: #f8f9fa; height: 400px; overflow-y: auto; } .log-entry { margin: 5px 0; padding: 5px; border-bottom: 1px solid #eee; } .log-entry.sent { color: #004085; background-color: #cce5ff; } .log-entry.received { color: #155724; background-color: #d4edda; } .log-entry.error { color: #721c24; background-color: #f8d7da; } #requestTemplates { margin-bottom: 20px; } </style> </head> <body> <div class="container"> <h1>MCP WebSocket Test Client</h1> <div id="connectionStatus" class="status disconnected">Disconnected</div> <div class="connection-controls"> <input type="text" id="wsUrl" value="ws://localhost:3000" style="width: 200px; margin-right: 10px" /> <button id="connectButton" class="button">Connect</button> <button id="disconnectButton" class="button" disabled> Disconnect </button> </div> <div class="message-box"> <h3>Request Templates</h3> <select id="requestTemplates" style="width: 100%; padding: 5px"> <option value="initialize">Initialize</option> <option value="ping">Ping</option> <option value="resourcesList">List Resources</option> <option value="toolsList">List Tools</option> <option value="promptsList">List Prompts</option> </select> <h3>Message</h3> <textarea id="messageInput" class="message-input" placeholder="Enter JSON message" ></textarea> <button id="sendButton" class="button" disabled>Send Message</button> </div> <div class="log" id="messageLog"></div> </div> <script> let ws = null; let nextRequestId = 1; const templates = { initialize: { jsonrpc: "2.0", method: "initialize", id: 1, params: { protocolVersion: "2024-11-05", capabilities: { sampling: {}, }, clientInfo: { name: "Web Test Client", version: "1.0.0", }, }, }, ping: { jsonrpc: "2.0", method: "ping", id: 1, }, resourcesList: { jsonrpc: "2.0", method: "resources/list", id: 1, }, toolsList: { jsonrpc: "2.0", method: "tools/list", id: 1, }, promptsList: { jsonrpc: "2.0", method: "prompts/list", id: 1, }, }; function updateConnectionStatus(connected) { const statusDiv = document.getElementById("connectionStatus"); const connectButton = document.getElementById("connectButton"); const disconnectButton = document.getElementById("disconnectButton"); const sendButton = document.getElementById("sendButton"); statusDiv.textContent = connected ? "Connected" : "Disconnected"; statusDiv.className = `status ${ connected ? "connected" : "disconnected" }`; connectButton.disabled = connected; disconnectButton.disabled = !connected; sendButton.disabled = !connected; } function addLogEntry(message, type = "received") { const logDiv = document.getElementById("messageLog"); const entry = document.createElement("div"); entry.className = `log-entry ${type}`; try { let jsonData = message; if (typeof message === "string") { jsonData = JSON.parse(message); } const formatter = new JSONFormatter(jsonData, 2, { hoverPreviewEnabled: true, hoverPreviewArrayCount: 100, hoverPreviewFieldCount: 5, }); entry.appendChild(formatter.render()); } catch (e) { console.error("Failed to format message:", e); entry.innerHTML = `<pre>${ typeof message === "object" ? JSON.stringify(message, null, 2) : String(message) }</pre>`; } logDiv.appendChild(entry); logDiv.scrollTop = logDiv.scrollHeight; } function connect() { const url = document.getElementById("wsUrl").value; try { ws = new WebSocket(url); ws.onopen = () => { updateConnectionStatus(true); addLogEntry("WebSocket connection established", "received"); }; ws.onclose = () => { updateConnectionStatus(false); addLogEntry("WebSocket connection closed", "error"); ws = null; }; ws.onerror = (error) => { addLogEntry(`WebSocket error: ${error}`, "error"); }; ws.onmessage = (event) => { // イベントデータがJSON形式の文字列でない場合に備えて処理 let data = event.data; try { // オブジェクトが文字列化されていない場合は文字列化 if (typeof data === "object") { data = JSON.stringify(data); } // 文字列をJSONとしてパースしてフォーマット const jsonData = JSON.parse(data); addLogEntry(jsonData, "received"); } catch (e) { // JSONとしてパースできない場合は生のデータを表示 console.error("Failed to parse message:", e); addLogEntry( { error: "Failed to parse message", data: data, details: e.toString(), }, "error" ); } }; } catch (error) { addLogEntry(`Failed to connect: ${error}`, "error"); } } function disconnect() { if (ws) { ws.close(); } } function sendMessage() { if (!ws) { addLogEntry("Not connected to server", "error"); return; } try { const messageInput = document.getElementById("messageInput"); const message = JSON.parse(messageInput.value); ws.send(JSON.stringify(message)); addLogEntry(message, "sent"); } catch (error) { addLogEntry(`Failed to send message: ${error}`, "error"); } } document.getElementById("connectButton").onclick = connect; document.getElementById("disconnectButton").onclick = disconnect; document.getElementById("sendButton").onclick = sendMessage; document.getElementById("requestTemplates").onchange = (e) => { const template = templates[e.target.value]; if (template) { template.id = nextRequestId++; document.getElementById("messageInput").value = JSON.stringify( template, null, 2 ); } }; // 初期化時にテンプレートを選択 document .getElementById("requestTemplates") .dispatchEvent(new Event("change")); </script> </body> </html> ```