# 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 |
```