# 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: -------------------------------------------------------------------------------- ``` 1 | /target 2 | .env 3 | .DS_Store 4 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP Server Runner 2 | 3 | > **Note**: This project is currently under active development and in WIP (Work In Progress) status. Features and APIs may change significantly. 4 | 5 | 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. 6 | 7 | ## Development Status 8 | 9 | - 🚧 **Work In Progress**: This software is in active development 10 | - ⚠️ **API Stability**: APIs and features may change without notice 11 | - 🧪 **Testing**: Currently undergoing testing and refinement 12 | - 📝 **Documentation**: Documentation is being actively updated 13 | 14 | ## Overview 15 | 16 | MCP Server Runner acts as a bridge between WebSocket clients and MCP server implementations. It: 17 | 18 | - Launches an MCP server process 19 | - Manages WebSocket connections 20 | - Handles bidirectional communication between clients and the MCP server 21 | - Supports graceful shutdown and error handling 22 | 23 | ## Features 24 | 25 | - WebSocket server implementation with single-client support 26 | - Process management for MCP server instances 27 | - Bidirectional message passing between client and server 28 | - Graceful shutdown handling 29 | - Comprehensive error logging 30 | - Cross-platform support (Unix/Windows) 31 | 32 | ## Prerequisites 33 | 34 | - Rust 1.70 or higher 35 | - An MCP server implementation executable 36 | 37 | ## Configuration 38 | 39 | ### Environment Variables 40 | 41 | The application can be configured through environment variables: 42 | 43 | ```env 44 | PROGRAM= # Path to the MCP server executable (required if no config file) 45 | ARGS= # Comma-separated list of arguments for the MCP server 46 | HOST=0.0.0.0 # Host address to bind to (default: 0.0.0.0) 47 | PORT=8080 # Port to listen on (default: 8080) 48 | CONFIG_FILE= # Path to JSON configuration file 49 | ``` 50 | 51 | Additional environment variables will be passed through to the MCP server process. 52 | 53 | ### JSON Configuration 54 | 55 | Alternatively, you can provide a JSON configuration file: 56 | 57 | ```json 58 | { 59 | "servers": { 60 | "filesystem": { 61 | "command": "npx", 62 | "args": [ 63 | "-y", 64 | "@modelcontextprotocol/server-filesystem", 65 | "/path/to/workspace" 66 | ] 67 | }, 68 | "github": { 69 | "command": "npx", 70 | "args": ["-y", "@modelcontextprotocol/server-github"], 71 | "env": { 72 | "GITHUB_PERSONAL_ACCESS_TOKEN": "your_token_here" 73 | } 74 | } 75 | }, 76 | "default_server": "filesystem", 77 | "host": "0.0.0.0", 78 | "port": 8080 79 | } 80 | ``` 81 | 82 | You can specify the configuration file in two ways: 83 | 84 | 1. As a command-line argument: `mcp-server-runner config.json` 85 | 2. Using the `CONFIG_FILE` environment variable: `CONFIG_FILE=config.json mcp-server-runner` 86 | 87 | The JSON configuration allows you to define multiple server configurations and select one as the default. 88 | 89 | ### Configuration Priority 90 | 91 | 1. Command-line specified config file 92 | 2. `CONFIG_FILE` environment variable 93 | 3. Environment variables (`PROGRAM`, `ARGS`, etc.) 94 | 4. Default values 95 | 96 | ## Usage 97 | 98 | 1. Using environment variables: 99 | 100 | ```bash 101 | export PROGRAM=npx 102 | export ARGS=-y,@modelcontextprotocol/server-github 103 | export PORT=8080 104 | export GITHUB_PERSONAL_ACCESS_TOKEN=github_pat_*** 105 | cargo run 106 | ``` 107 | 108 | 2. Using a configuration file: 109 | 110 | ```bash 111 | # Either specify the config file as an argument 112 | cargo run config.json 113 | 114 | # Or use the CONFIG_FILE environment variable 115 | CONFIG_FILE=config.json cargo run 116 | ``` 117 | 118 | 3. Connect to the WebSocket server: 119 | ```javascript 120 | const ws = new WebSocket("ws://localhost:8080"); 121 | ``` 122 | 123 | ## Docker Support 124 | 125 | A Dockerfile and docker-compose.yml are provided for containerized deployment: 126 | 127 | ```bash 128 | docker-compose up --build 129 | ``` 130 | 131 | ## Development 132 | 133 | Build the project: 134 | 135 | ```bash 136 | cargo build 137 | ``` 138 | 139 | Run tests: 140 | 141 | ```bash 142 | cargo test 143 | ``` 144 | 145 | Run with debug logging: 146 | 147 | ```bash 148 | RUST_LOG=debug cargo run 149 | ``` 150 | 151 | ## Architecture 152 | 153 | The application follows a modular architecture: 154 | 155 | - `main.rs`: Application entry point and server setup 156 | - `config/`: Configuration loading and management 157 | - `process/`: Process management and I/O handling 158 | - `websocket/`: WebSocket connection management 159 | - `state.rs`: Global state management 160 | - `shutdown.rs`: Graceful shutdown handling 161 | 162 | ## Error Handling 163 | 164 | - Standard error output from the MCP server is logged but not forwarded to clients 165 | - WebSocket connection errors are handled gracefully 166 | - Process errors are logged with detailed information 167 | 168 | ## Limitations 169 | 170 | - Supports only one client connection at a time 171 | - Does not support WebSocket SSL/TLS (use a reverse proxy for secure connections) 172 | - No built-in authentication mechanism 173 | 174 | ## Contributing 175 | 176 | 1. Fork the repository 177 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 178 | 3. Commit your changes (`git commit -m 'Add amazing feature'`) 179 | 4. Push to the branch (`git push origin feature/amazing-feature`) 180 | 5. Open a Pull Request 181 | 182 | ## License 183 | 184 | This project is licensed under the MIT License - see the LICENSE file for details. 185 | 186 | ## Additional Resources 187 | 188 | - [Model Context Protocol Specification](https://github.com/modelcontextprotocol/specification) 189 | - [WebSocket Protocol (RFC 6455)](https://tools.ietf.org/html/rfc6455) 190 | ``` -------------------------------------------------------------------------------- /src/process/mod.rs: -------------------------------------------------------------------------------- ```rust 1 | mod io; 2 | mod manager; 3 | 4 | pub use manager::ProcessManager; ``` -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- ```rust 1 | pub mod model; 2 | mod loader; 3 | 4 | pub use loader::load_config; ``` -------------------------------------------------------------------------------- /src/constants.rs: -------------------------------------------------------------------------------- ```rust 1 | /// Size of the message buffer for communication channels. 2 | /// This value affects the capacity of mpsc channels used for 3 | /// process and WebSocket communication. 4 | pub const MESSAGE_BUFFER_SIZE: usize = 100; ``` -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "servers": { 3 | "github": { 4 | "command": "npx", 5 | "args": ["-y", "@modelcontextprotocol/server-github"], 6 | "env": { 7 | "GITHUB_PERSONAL_ACCESS_TOKEN": "your_token_here" 8 | } 9 | } 10 | }, 11 | "default_server": "github", 12 | "host": "0.0.0.0", 13 | "port": 8080 14 | } 15 | 16 | ``` -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- ```rust 1 | pub mod config; 2 | mod constants; 3 | mod process; 4 | mod shutdown; 5 | mod state; 6 | mod websocket; 7 | 8 | // Re-export public API 9 | pub use constants::MESSAGE_BUFFER_SIZE; 10 | pub use process::ProcessManager; 11 | pub use shutdown::shutdown_signal; 12 | pub use websocket::handle_connection; 13 | pub use state::{CONNECTED, SHUTDOWN}; ``` -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- ```toml 1 | [package] 2 | name = "mcp-server-runner" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | tokio = { version = "1.0", features = ["full"] } 8 | tokio-tungstenite = "0.20" 9 | futures-util = "0.3" 10 | log = "0.4" 11 | env_logger = "0.10" 12 | anyhow = "1.0" 13 | serde = { version = "1.0", features = ["derive"] } 14 | serde_json = "1.0" 15 | ``` -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- ```yaml 1 | version: "3.9" 2 | 3 | services: 4 | mcp-server: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - "3000:8080" 10 | environment: 11 | - PROGRAM=${PROGRAM} 12 | - ARGS=${ARGS} 13 | - GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN} 14 | - RUST_LOG=debug 15 | volumes: 16 | - ./data:/app/data 17 | restart: always 18 | ``` -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- ```rust 1 | use std::sync::atomic::AtomicBool; 2 | 3 | /// Indicates whether a client is currently connected to the WebSocket server. 4 | /// Used to ensure only one client can be connected at a time. 5 | pub static CONNECTED: AtomicBool = AtomicBool::new(false); 6 | 7 | /// Global shutdown flag to signal all components to terminate. 8 | /// When set to true, all async tasks should gracefully shut down. 9 | pub static SHUTDOWN: AtomicBool = AtomicBool::new(false); ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | FROM rust:latest AS chef 2 | RUN cargo install cargo-chef 3 | WORKDIR /app 4 | 5 | FROM chef AS planner 6 | COPY . . 7 | RUN cargo chef prepare --recipe-path recipe.json 8 | 9 | FROM chef AS builder 10 | COPY --from=planner /app/recipe.json recipe.json 11 | RUN cargo chef cook --release --recipe-path recipe.json 12 | COPY . . 13 | RUN cargo build --release 14 | 15 | FROM debian:bookworm-slim AS runtime 16 | WORKDIR /app 17 | RUN apt-get update && apt-get install -y --no-install-recommends \ 18 | nodejs npm python3 python3-pip curl && \ 19 | curl -LsSf https://astral.sh/uv/install.sh | sh && \ 20 | apt-get clean && rm -rf /var/lib/apt/lists/* /root/.npm /root/.cache 21 | COPY --from=builder /app/target/release/mcp-server-runner /usr/local/bin/ 22 | CMD ["mcp-server-runner"] 23 | ``` -------------------------------------------------------------------------------- /src/shutdown.rs: -------------------------------------------------------------------------------- ```rust 1 | use log::info; 2 | use tokio::signal; 3 | use std::sync::atomic::Ordering; 4 | 5 | use crate::state::SHUTDOWN; 6 | 7 | /// Handles shutdown signals for the application. 8 | /// Listens for Ctrl+C and termination signals (on Unix systems), 9 | /// and sets the global shutdown flag when received. 10 | pub async fn shutdown_signal() { 11 | wait_for_shutdown_signal().await; 12 | initiate_shutdown(); 13 | } 14 | 15 | /// Waits for either Ctrl+C or termination signal. 16 | async fn wait_for_shutdown_signal() { 17 | let ctrl_c = setup_ctrl_c(); 18 | let terminate = setup_terminate(); 19 | 20 | tokio::select! { 21 | _ = ctrl_c => info!("Ctrl+C received"), 22 | _ = terminate => info!("Termination signal received"), 23 | } 24 | } 25 | 26 | /// Sets up Ctrl+C signal handler. 27 | async fn setup_ctrl_c() { 28 | signal::ctrl_c() 29 | .await 30 | .expect("Failed to install Ctrl+C handler"); 31 | } 32 | 33 | /// Sets up termination signal handler (Unix only). 34 | #[cfg(unix)] 35 | async fn setup_terminate() { 36 | signal::unix::signal(signal::unix::SignalKind::terminate()) 37 | .expect("Failed to install signal handler") 38 | .recv() 39 | .await; 40 | } 41 | 42 | /// Placeholder for non-Unix systems. 43 | #[cfg(not(unix))] 44 | async fn setup_terminate() { 45 | std::future::pending::<()>().await 46 | } 47 | 48 | /// Initiates the shutdown process by setting the global shutdown flag. 49 | fn initiate_shutdown() { 50 | info!("Initiating shutdown sequence"); 51 | SHUTDOWN.store(true, Ordering::SeqCst); 52 | } ``` -------------------------------------------------------------------------------- /src/websocket/mod.rs: -------------------------------------------------------------------------------- ```rust 1 | mod message; 2 | 3 | use anyhow::Result; 4 | use log::info; 5 | use tokio::net::TcpStream; 6 | use tokio::sync::mpsc; 7 | use tokio_tungstenite::accept_async; 8 | use std::sync::atomic::Ordering; 9 | use futures_util::StreamExt; 10 | 11 | use crate::state::CONNECTED; 12 | use self::message::{handle_incoming_messages, handle_outgoing_messages}; 13 | 14 | /// Handle a new WebSocket connection 15 | pub async fn handle_connection( 16 | stream: TcpStream, 17 | process_tx: mpsc::Sender<String>, 18 | ws_rx: mpsc::Receiver<String>, 19 | ) -> Result<()> { 20 | let addr = setup_connection(&stream)?; 21 | let ws_stream = accept_async(stream).await?; 22 | 23 | info!("WebSocket connection established: {}", addr); 24 | let (ws_writer, ws_reader) = ws_stream.split(); 25 | 26 | let ws_to_process = handle_incoming_messages(ws_reader, process_tx); 27 | let process_to_ws = handle_outgoing_messages(ws_writer, ws_rx); 28 | 29 | tokio::select! { 30 | _ = ws_to_process => info!("WebSocket -> Process handling completed"), 31 | _ = process_to_ws => info!("Process -> WebSocket handling completed"), 32 | } 33 | 34 | cleanup_connection(addr); 35 | Ok(()) 36 | } 37 | 38 | /// Set up initial connection state 39 | fn setup_connection(stream: &TcpStream) -> Result<std::net::SocketAddr> { 40 | let addr = stream.peer_addr()?; 41 | CONNECTED.store(true, Ordering::SeqCst); 42 | Ok(addr) 43 | } 44 | 45 | /// Clean up connection state 46 | fn cleanup_connection(addr: std::net::SocketAddr) { 47 | CONNECTED.store(false, Ordering::SeqCst); 48 | info!("Client disconnected: {}", addr); 49 | } ``` -------------------------------------------------------------------------------- /src/websocket/message.rs: -------------------------------------------------------------------------------- ```rust 1 | use std::sync::atomic::Ordering; 2 | use futures_util::{SinkExt, StreamExt}; 3 | use log::{debug, error}; 4 | use tokio::sync::mpsc; 5 | use tokio_tungstenite::tungstenite::protocol::Message; 6 | use futures_util::sink::Sink; 7 | 8 | use crate::state::SHUTDOWN; 9 | 10 | pub async fn handle_incoming_messages<S>( 11 | mut reader: S, 12 | process_tx: mpsc::Sender<String>, 13 | ) where 14 | S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin, 15 | { 16 | while let Some(msg) = reader.next().await { 17 | if SHUTDOWN.load(Ordering::SeqCst) { 18 | break; 19 | } 20 | 21 | match process_incoming_message(msg, &process_tx).await { 22 | Ok(should_break) => { 23 | if should_break { 24 | break; 25 | } 26 | } 27 | Err(e) => { 28 | error!("Error processing incoming message: {}", e); 29 | break; 30 | } 31 | } 32 | } 33 | } 34 | 35 | pub async fn handle_outgoing_messages<S>( 36 | mut writer: S, 37 | mut ws_rx: mpsc::Receiver<String>, 38 | ) where 39 | S: Sink<Message> + Unpin, 40 | S::Error: std::fmt::Debug, 41 | { 42 | while let Some(msg) = ws_rx.recv().await { 43 | if SHUTDOWN.load(Ordering::SeqCst) { 44 | break; 45 | } 46 | 47 | debug!("Sending process response: {}", msg); 48 | if let Err(e) = writer.send(Message::Text(msg)).await { 49 | error!("Error sending to WebSocket: {:?}", e); 50 | break; 51 | } 52 | } 53 | } 54 | 55 | async fn process_incoming_message( 56 | msg: Result<Message, tokio_tungstenite::tungstenite::Error>, 57 | process_tx: &mpsc::Sender<String>, 58 | ) -> Result<bool, Box<dyn std::error::Error>> { 59 | match msg { 60 | Ok(msg) => { 61 | if msg.is_close() { 62 | return Ok(true); 63 | } 64 | if let Ok(text) = msg.into_text() { 65 | debug!("Received from client: {}", text); 66 | process_tx.send(text).await?; 67 | } 68 | } 69 | Err(e) => { 70 | error!("Error receiving from WebSocket: {}", e); 71 | return Ok(true); 72 | } 73 | } 74 | Ok(false) 75 | } ``` -------------------------------------------------------------------------------- /src/process/manager.rs: -------------------------------------------------------------------------------- ```rust 1 | use anyhow::{Context, Result}; 2 | use log::{debug, error}; 3 | use std::collections::HashMap; 4 | use tokio::process::{Child, Command}; 5 | use tokio::sync::mpsc; 6 | 7 | use super::io::{handle_stdin, handle_stdout, handle_stderr}; 8 | use crate::constants::MESSAGE_BUFFER_SIZE; 9 | 10 | pub struct ProcessManager { 11 | child: Option<Child>, 12 | } 13 | 14 | impl ProcessManager { 15 | pub fn new() -> Self { 16 | Self { child: None } 17 | } 18 | 19 | pub async fn start_process( 20 | &mut self, 21 | program: &str, 22 | args: &[String], 23 | env_vars: &HashMap<String, String>, 24 | websocket_tx: mpsc::Sender<String>, 25 | ) -> Result<mpsc::Sender<String>> { 26 | let child = self.spawn_process(program, args, env_vars)?; 27 | let (process_tx, process_rx) = mpsc::channel::<String>(MESSAGE_BUFFER_SIZE); 28 | 29 | self.setup_io_handlers(child, process_rx, websocket_tx)?; 30 | 31 | Ok(process_tx) 32 | } 33 | 34 | fn spawn_process( 35 | &mut self, 36 | program: &str, 37 | args: &[String], 38 | env_vars: &HashMap<String, String>, 39 | ) -> Result<Child> { 40 | let mut command = Command::new(program); 41 | 42 | if !args.is_empty() { 43 | command.args(args); 44 | } 45 | 46 | for (key, value) in env_vars { 47 | command.env(key, value); 48 | } 49 | 50 | debug!("Spawning process: {} {:?}", program, args); 51 | 52 | let child = command 53 | .stdin(std::process::Stdio::piped()) 54 | .stdout(std::process::Stdio::piped()) 55 | .stderr(std::process::Stdio::piped()) 56 | .spawn()?; 57 | 58 | Ok(child) 59 | } 60 | 61 | fn setup_io_handlers( 62 | &mut self, 63 | mut child: Child, 64 | process_rx: mpsc::Receiver<String>, 65 | websocket_tx: mpsc::Sender<String>, 66 | ) -> Result<()> { 67 | let stdin = child.stdin.take().context("Failed to get child stdin")?; 68 | let stdout = child.stdout.take().context("Failed to get child stdout")?; 69 | let stderr = child.stderr.take().context("Failed to get child stderr")?; 70 | 71 | self.child = Some(child); 72 | 73 | tokio::spawn(handle_stdin(stdin, process_rx)); 74 | tokio::spawn(handle_stdout(stdout, websocket_tx)); 75 | tokio::spawn(handle_stderr(stderr)); 76 | 77 | Ok(()) 78 | } 79 | 80 | pub async fn shutdown(&mut self) { 81 | if let Some(mut child) = self.child.take() { 82 | debug!("Stopping child process..."); 83 | if let Err(e) = child.kill().await { 84 | error!("Failed to stop child process: {}", e); 85 | } 86 | if let Err(e) = child.wait().await { 87 | error!("Error waiting for child process to exit: {}", e); 88 | } 89 | debug!("Child process stopped"); 90 | } 91 | } 92 | } ``` -------------------------------------------------------------------------------- /src/process/io.rs: -------------------------------------------------------------------------------- ```rust 1 | use log::{debug, error, info, warn}; 2 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; 3 | use tokio::process::{ChildStdin, ChildStdout, ChildStderr}; 4 | use tokio::sync::mpsc; 5 | use std::sync::atomic::Ordering; 6 | 7 | use crate::state::SHUTDOWN; 8 | 9 | pub async fn handle_stdin( 10 | stdin: ChildStdin, 11 | mut process_rx: mpsc::Receiver<String>, 12 | ) { 13 | let mut writer = BufWriter::new(stdin); 14 | debug!("Started stdin handler for child process"); 15 | 16 | while let Some(message) = process_rx.recv().await { 17 | if SHUTDOWN.load(Ordering::SeqCst) { 18 | debug!("Shutdown signal received, stopping stdin handler"); 19 | break; 20 | } 21 | 22 | debug!("Received message to send to process. Length: {}", message.len()); 23 | if let Err(e) = write_to_process(&mut writer, &message).await { 24 | error!("Error in stdin handling: {}. Message was: {}", e, message); 25 | break; 26 | } 27 | debug!("Successfully wrote message to process"); 28 | } 29 | info!("Stdin handler finished"); 30 | } 31 | 32 | pub async fn handle_stdout( 33 | stdout: ChildStdout, 34 | websocket_tx: mpsc::Sender<String>, 35 | ) { 36 | let mut reader = BufReader::new(stdout); 37 | let mut line = String::new(); 38 | debug!("Started stdout handler for child process"); 39 | 40 | while let Ok(n) = reader.read_line(&mut line).await { 41 | if should_stop(n) { 42 | debug!("Stopping stdout handler: {}", 43 | if n == 0 { "EOF reached" } else { "shutdown requested" }); 44 | break; 45 | } 46 | 47 | let trimmed = line.trim().to_string(); 48 | debug!("Received from process (stdout) - Length: {}, Content: {}", 49 | trimmed.len(), trimmed); 50 | 51 | if let Err(e) = websocket_tx.send(trimmed).await { 52 | error!("Error sending to WebSocket: {}", e); 53 | break; 54 | } 55 | debug!("Successfully sent process output to WebSocket"); 56 | line.clear(); 57 | } 58 | info!("Stdout handler finished"); 59 | } 60 | 61 | pub async fn handle_stderr(stderr: ChildStderr) { 62 | let mut reader = BufReader::new(stderr); 63 | let mut line = String::new(); 64 | debug!("Started stderr handler for child process"); 65 | 66 | while let Ok(n) = reader.read_line(&mut line).await { 67 | if should_stop(n) { 68 | debug!("Stopping stderr handler: {}", 69 | if n == 0 { "EOF reached" } else { "shutdown requested" }); 70 | break; 71 | } 72 | 73 | let trimmed = line.trim(); 74 | warn!("Process stderr: {}", trimmed); 75 | line.clear(); 76 | } 77 | info!("Stderr handler finished"); 78 | } 79 | 80 | async fn write_to_process( 81 | writer: &mut BufWriter<ChildStdin>, 82 | message: &str, 83 | ) -> tokio::io::Result<()> { 84 | debug!("Writing to process - Length: {}, Content: {}", message.len(), message); 85 | writer.write_all(message.as_bytes()).await?; 86 | writer.write_all(b"\n").await?; 87 | writer.flush().await?; 88 | debug!("Successfully flushed message to process"); 89 | Ok(()) 90 | } 91 | 92 | fn should_stop(n: usize) -> bool { 93 | n == 0 || SHUTDOWN.load(Ordering::SeqCst) 94 | } 95 | ``` -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- ```html 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8" /> 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 | <title>MCP WebSocket Test Client</title> 7 | <script src="https://cdnjs.cloudflare.com/ajax/libs/json-formatter/0.7.2/json-formatter.min.js"></script> 8 | <style> 9 | body { 10 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 11 | "Helvetica Neue", Arial, sans-serif; 12 | max-width: 1200px; 13 | margin: 0 auto; 14 | padding: 20px; 15 | background: #f5f5f5; 16 | } 17 | .container { 18 | background: white; 19 | padding: 20px; 20 | border-radius: 8px; 21 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 22 | } 23 | .status { 24 | padding: 10px; 25 | margin-bottom: 20px; 26 | border-radius: 4px; 27 | } 28 | .status.connected { 29 | background-color: #d4edda; 30 | color: #155724; 31 | border: 1px solid #c3e6cb; 32 | } 33 | .status.disconnected { 34 | background-color: #f8d7da; 35 | color: #721c24; 36 | border: 1px solid #f5c6cb; 37 | } 38 | .message-box { 39 | margin: 20px 0; 40 | } 41 | .message-input { 42 | width: 100%; 43 | height: 100px; 44 | margin: 10px 0; 45 | padding: 10px; 46 | border: 1px solid #ddd; 47 | border-radius: 4px; 48 | font-family: monospace; 49 | } 50 | .button { 51 | background-color: #007bff; 52 | color: white; 53 | border: none; 54 | padding: 10px 20px; 55 | border-radius: 4px; 56 | cursor: pointer; 57 | } 58 | .button:hover { 59 | background-color: #0056b3; 60 | } 61 | .button:disabled { 62 | background-color: #ccc; 63 | cursor: not-allowed; 64 | } 65 | .log { 66 | margin-top: 20px; 67 | border: 1px solid #ddd; 68 | padding: 10px; 69 | border-radius: 4px; 70 | background: #f8f9fa; 71 | height: 400px; 72 | overflow-y: auto; 73 | } 74 | .log-entry { 75 | margin: 5px 0; 76 | padding: 5px; 77 | border-bottom: 1px solid #eee; 78 | } 79 | .log-entry.sent { 80 | color: #004085; 81 | background-color: #cce5ff; 82 | } 83 | .log-entry.received { 84 | color: #155724; 85 | background-color: #d4edda; 86 | } 87 | .log-entry.error { 88 | color: #721c24; 89 | background-color: #f8d7da; 90 | } 91 | #requestTemplates { 92 | margin-bottom: 20px; 93 | } 94 | </style> 95 | </head> 96 | <body> 97 | <div class="container"> 98 | <h1>MCP WebSocket Test Client</h1> 99 | 100 | <div id="connectionStatus" class="status disconnected">Disconnected</div> 101 | 102 | <div class="connection-controls"> 103 | <input 104 | type="text" 105 | id="wsUrl" 106 | value="ws://localhost:3000" 107 | style="width: 200px; margin-right: 10px" 108 | /> 109 | <button id="connectButton" class="button">Connect</button> 110 | <button id="disconnectButton" class="button" disabled> 111 | Disconnect 112 | </button> 113 | </div> 114 | 115 | <div class="message-box"> 116 | <h3>Request Templates</h3> 117 | <select id="requestTemplates" style="width: 100%; padding: 5px"> 118 | <option value="initialize">Initialize</option> 119 | <option value="ping">Ping</option> 120 | <option value="resourcesList">List Resources</option> 121 | <option value="toolsList">List Tools</option> 122 | <option value="promptsList">List Prompts</option> 123 | </select> 124 | <h3>Message</h3> 125 | <textarea 126 | id="messageInput" 127 | class="message-input" 128 | placeholder="Enter JSON message" 129 | ></textarea> 130 | <button id="sendButton" class="button" disabled>Send Message</button> 131 | </div> 132 | 133 | <div class="log" id="messageLog"></div> 134 | </div> 135 | 136 | <script> 137 | let ws = null; 138 | let nextRequestId = 1; 139 | 140 | const templates = { 141 | initialize: { 142 | jsonrpc: "2.0", 143 | method: "initialize", 144 | id: 1, 145 | params: { 146 | protocolVersion: "2024-11-05", 147 | capabilities: { 148 | sampling: {}, 149 | }, 150 | clientInfo: { 151 | name: "Web Test Client", 152 | version: "1.0.0", 153 | }, 154 | }, 155 | }, 156 | ping: { 157 | jsonrpc: "2.0", 158 | method: "ping", 159 | id: 1, 160 | }, 161 | resourcesList: { 162 | jsonrpc: "2.0", 163 | method: "resources/list", 164 | id: 1, 165 | }, 166 | toolsList: { 167 | jsonrpc: "2.0", 168 | method: "tools/list", 169 | id: 1, 170 | }, 171 | promptsList: { 172 | jsonrpc: "2.0", 173 | method: "prompts/list", 174 | id: 1, 175 | }, 176 | }; 177 | 178 | function updateConnectionStatus(connected) { 179 | const statusDiv = document.getElementById("connectionStatus"); 180 | const connectButton = document.getElementById("connectButton"); 181 | const disconnectButton = document.getElementById("disconnectButton"); 182 | const sendButton = document.getElementById("sendButton"); 183 | 184 | statusDiv.textContent = connected ? "Connected" : "Disconnected"; 185 | statusDiv.className = `status ${ 186 | connected ? "connected" : "disconnected" 187 | }`; 188 | 189 | connectButton.disabled = connected; 190 | disconnectButton.disabled = !connected; 191 | sendButton.disabled = !connected; 192 | } 193 | 194 | function addLogEntry(message, type = "received") { 195 | const logDiv = document.getElementById("messageLog"); 196 | const entry = document.createElement("div"); 197 | entry.className = `log-entry ${type}`; 198 | 199 | try { 200 | let jsonData = message; 201 | if (typeof message === "string") { 202 | jsonData = JSON.parse(message); 203 | } 204 | const formatter = new JSONFormatter(jsonData, 2, { 205 | hoverPreviewEnabled: true, 206 | hoverPreviewArrayCount: 100, 207 | hoverPreviewFieldCount: 5, 208 | }); 209 | entry.appendChild(formatter.render()); 210 | } catch (e) { 211 | console.error("Failed to format message:", e); 212 | entry.innerHTML = `<pre>${ 213 | typeof message === "object" 214 | ? JSON.stringify(message, null, 2) 215 | : String(message) 216 | }</pre>`; 217 | } 218 | 219 | logDiv.appendChild(entry); 220 | logDiv.scrollTop = logDiv.scrollHeight; 221 | } 222 | 223 | function connect() { 224 | const url = document.getElementById("wsUrl").value; 225 | try { 226 | ws = new WebSocket(url); 227 | 228 | ws.onopen = () => { 229 | updateConnectionStatus(true); 230 | addLogEntry("WebSocket connection established", "received"); 231 | }; 232 | 233 | ws.onclose = () => { 234 | updateConnectionStatus(false); 235 | addLogEntry("WebSocket connection closed", "error"); 236 | ws = null; 237 | }; 238 | 239 | ws.onerror = (error) => { 240 | addLogEntry(`WebSocket error: ${error}`, "error"); 241 | }; 242 | 243 | ws.onmessage = (event) => { 244 | // イベントデータがJSON形式の文字列でない場合に備えて処理 245 | let data = event.data; 246 | try { 247 | // オブジェクトが文字列化されていない場合は文字列化 248 | if (typeof data === "object") { 249 | data = JSON.stringify(data); 250 | } 251 | // 文字列をJSONとしてパースしてフォーマット 252 | const jsonData = JSON.parse(data); 253 | addLogEntry(jsonData, "received"); 254 | } catch (e) { 255 | // JSONとしてパースできない場合は生のデータを表示 256 | console.error("Failed to parse message:", e); 257 | addLogEntry( 258 | { 259 | error: "Failed to parse message", 260 | data: data, 261 | details: e.toString(), 262 | }, 263 | "error" 264 | ); 265 | } 266 | }; 267 | } catch (error) { 268 | addLogEntry(`Failed to connect: ${error}`, "error"); 269 | } 270 | } 271 | 272 | function disconnect() { 273 | if (ws) { 274 | ws.close(); 275 | } 276 | } 277 | 278 | function sendMessage() { 279 | if (!ws) { 280 | addLogEntry("Not connected to server", "error"); 281 | return; 282 | } 283 | 284 | try { 285 | const messageInput = document.getElementById("messageInput"); 286 | const message = JSON.parse(messageInput.value); 287 | ws.send(JSON.stringify(message)); 288 | addLogEntry(message, "sent"); 289 | } catch (error) { 290 | addLogEntry(`Failed to send message: ${error}`, "error"); 291 | } 292 | } 293 | 294 | document.getElementById("connectButton").onclick = connect; 295 | document.getElementById("disconnectButton").onclick = disconnect; 296 | document.getElementById("sendButton").onclick = sendMessage; 297 | 298 | document.getElementById("requestTemplates").onchange = (e) => { 299 | const template = templates[e.target.value]; 300 | if (template) { 301 | template.id = nextRequestId++; 302 | document.getElementById("messageInput").value = JSON.stringify( 303 | template, 304 | null, 305 | 2 306 | ); 307 | } 308 | }; 309 | 310 | // 初期化時にテンプレートを選択 311 | document 312 | .getElementById("requestTemplates") 313 | .dispatchEvent(new Event("change")); 314 | </script> 315 | </body> 316 | </html> 317 | ```