#
tokens: 6841/50000 17/17 files
lines: off (toggle) GitHub
raw markdown copy
# 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>

```