mirror of
https://github.com/BloopAI/dev-manager-mcp.git
synced 2026-08-23 11:58:33 +00:00
MCP Dev Server Manager (vibe-kanban 2d397bea)
## Goal
Build a **daemon process** that runs continuously and accepts multiple concurrent MCP client connections, sharing dev server session state across all clients.
## Prerequisites
- Rust toolchain
- rmcp SDK from github.com/modelcontextprotocol/rust-sdk
- Version pin: Use git reference with features: `["server", "transport-sse", "macros"]`
- Additional deps: tokio (full), serde, anyhow, async-trait, axum (for HTTP/SSE transport)
## Architecture Overview
**Multi-client daemon with shared state:**
1. **HTTP/SSE server** listening on configurable port (default 3009)
2. **Multiple concurrent MCP clients** connecting via HTTP
3. **Shared global state** `Arc<Mutex<HashMap<SessionKey, ServerEntry>>>`
4. Dev server process manager
5. Port allocator starting at 3010
6. Log ring buffers (512KB per server)
7. Background sweeper (5s interval, 60s timeout)
**State:** `Arc<Manager>` containing `Arc<Mutex<HashMap>>` shared across all client connections
## Implementation Steps (Bottom-Up)
### 1. Port Allocator Module
- Sequential allocation from 3010
- Maintain free list (VecDeque) for reused ports
- Probe availability with TcpListener::bind before assignment
- Track in-use set to prevent collisions
- Handle overflow at u16::MAX boundary
### 2. Log Buffer Module
- Bounded VecDeque<String> with byte tracking
- 512KB total capacity (track bytes not line count)
- Evict oldest when full
- **Must derive Clone** for async reader tasks
- tail() method returns tuple: (logs_string, truncated_bool)
### 3. Server Entry Module
- Wraps tokio::process::Child
- Captures stdout/stderr via piped stdio
- Spawns two async tasks to read pipes into cloned LogBuffer
- Tracks last_activity as Instant for sweeper
- stop() method: child.kill() then child.wait() with timeout
### 4. Manager Module
- **Must derive Clone** for sharing across connections
- Holds Arc<Mutex> to server HashMap and port allocator
- Methods: start, stop, status, tail (all return serde_json::Value)
- start_sweeper() spawns tokio task **once** in constructor:
- Runs every 5 seconds
- Finds entries idle >60s
- Kills process, frees port, removes from map
- **Critical:** All tool methods (start/stop/status/tail) update last_activity timestamp
### 5. HTTP/SSE Transport Setup
#### Daemon Main Function
```rust
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Create shared manager (single instance)
let manager = Arc::new(Manager::new());
// Create service factory (clones manager for each connection)
let service_factory = move || {
let manager = manager.clone();
async move { Ok(DevManagerService::new(manager)) }
};
// Start HTTP/SSE server
let addr = "127.0.0.1:3009".parse()?;
println!("MCP daemon listening on {}", addr);
rmcp::transport::sse::serve(addr, service_factory).await?;
Ok(())
}
```
#### Service Struct Pattern
- Constructor takes `Arc<Manager>` as parameter
- Each connection gets fresh service instance with shared manager
- Derive Clone on service struct
- Include field: `tool_router: ToolRouter<Self>`
- Constructor calls `Self::tool_router()` (macro-generated)
#### Tool Registration
- Use `#[tool_router]` attribute on impl block containing tools
- Each tool method gets `#[tool(description = "...")]` attribute
- Tool signature: `async fn name(&self, Parameters(req): Parameters<ReqType>) -> Result<CallToolResult, McpError>`
- Return `CallToolResult::success(vec![Content::text(...)])` or `::error(...)`
- **Never return String directly** - always wrap in CallToolResult
#### ServerHandler Implementation
- Add `#[tool_handler]` attribute to link tools
- Implement get_info() returning ServerInfo:
- protocol_version: ProtocolVersion::V_2024_11_05
- capabilities: ServerCapabilities::builder().enable_tools().build()
- server_info: Implementation::from_build_env()
- instructions: Some(String)
- Implement initialize() with signature:
- `async fn initialize(&self, _params: InitializeRequestParam, _context: RequestContext<RoleServer>) -> Result<InitializeResult, McpError>`
## Critical Details to Avoid Errors
### Compile-Time Requirements
1. Use `transport-sse` feature (not `transport-io`)
2. Service struct and Manager must derive Clone
3. All param structs need Deserialize + JsonSchema + Clone
4. LogBuffer needs Clone for async task cloning
5. Use Parameters<T> wrapper in tool signatures
6. Import ErrorData as McpError (not error::Error - private module)
7. tool_router field must exist in service struct
8. Use rmcp::schemars::JsonSchema (not direct schemars dependency)
### Runtime Requirements
1. Call start_sweeper() in Manager::new() **once**
2. Spawn stdout/stderr reader tasks asynchronously (don't block)
3. Update last_activity in ALL tool methods (not just start)
4. Always child.wait() after child.kill() to prevent zombies
5. Free port back to allocator when stopping server
6. **Don't hold MutexGuard across await points** (use scoped blocks)
### Common Gotchas
- Don't start sweeper per connection (start it once in Manager::new())
- Drop mutex guards before awaiting futures (not Send)
- Port 3009 for daemon, 3010+ for managed dev servers
- SSE endpoint typically at `/sse` path
- Implementation::from_build_env() not ::new()
- Port overflow check: `port == u16::MAX` not `port > 65535`
- CallToolResult not String - use builder methods
- tool_router() is macro-generated, not manual
## Testing Checklist
- [ ] Build succeeds: `cargo build --release`
- [ ] Daemon starts and listens on port 3009
- [ ] **Multiple clients can connect simultaneously**
- [ ] **All clients see same session state**
- [ ] Client A starts server → Client B sees it in status
- [ ] Port allocation starts at 3010
- [ ] Same session_key reuses same port after restart
- [ ] Stdout/stderr captured in logs
- [ ] Server auto-stops after 60s without calls from ANY client
- [ ] Multiple sessions get different ports
- [ ] Stopped servers free their ports
- [ ] Daemon survives client disconnects
## File Structure
```
src/
main.rs - Daemon, HTTP/SSE server, service factory
service.rs - DevManagerService with tools, ServerHandler
manager.rs - Session manager, sweeper (shared state)
port_allocator.rs - Port assignment logic
server_entry.rs - Process wrapper, log capture
log_buffer.rs - Ring buffer implementation
```
## Implementation Order
1. Create port allocator (simplest, no dependencies)
2. Create log buffer (simple, needs Clone)
3. Create server entry (uses log buffer)
4. Create manager (uses allocator + server entry, starts sweeper)
5. Build minimal HTTP/SSE daemon skeleton with service factory
6. Create service module with one dummy tool (verify rmcp setup)
7. Wire manager into MCP tools
8. Test with **two concurrent clients** connecting
9. Verify state sharing works correctly
10. Polish error handling and edge cases
## Usage After Build
### Starting the Daemon
```bash
# Start daemon in foreground
./target/release/mcp-dev-manager
# Or background
./target/release/mcp-dev-manager &
```
### Client Configuration
For Claude Desktop (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"dev-manager": {
"url": "http://127.0.0.1:3009/sse"
}
}
}
```
Multiple clients can use the same URL and will share session state.
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
1318
Cargo.lock
generated
Normal file
1318
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
Normal file
14
Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "mcp-dev-manager"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
rmcp = { version = "0.8", features = ["server", "transport-sse-server"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
schemars = "1.0.4"
|
||||
rand = "0.8"
|
||||
164
README.md
Normal file
164
README.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# MCP Dev Server Manager
|
||||
|
||||
A daemon process that runs continuously and accepts multiple concurrent MCP client connections, sharing dev server session state across all clients.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-client daemon** with HTTP/SSE transport on port 3009
|
||||
- **Shared global state** across all concurrent connections
|
||||
- **Automatic port allocation** starting at 3010 with reuse
|
||||
- **Log capture** with 512KB ring buffers per server
|
||||
- **Auto-cleanup** of idle sessions after 60 seconds
|
||||
- **Process management** with stdout/stderr capture
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The binary will be at `target/release/mcp-dev-manager`.
|
||||
|
||||
## Running
|
||||
|
||||
### Start the Daemon
|
||||
|
||||
```bash
|
||||
# Foreground
|
||||
./target/release/mcp-dev-manager
|
||||
|
||||
# Background
|
||||
./target/release/mcp-dev-manager &
|
||||
```
|
||||
|
||||
The daemon will listen on `http://127.0.0.1:3009`.
|
||||
|
||||
### Client Configuration
|
||||
|
||||
For Claude Desktop (`claude_desktop_config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dev-manager": {
|
||||
"url": "http://127.0.0.1:3009/sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Multiple clients can use the same URL and will share session state.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### `start`
|
||||
Start a development server. Auto-generates a unique 4-character session key.
|
||||
|
||||
**Parameters:**
|
||||
- `command` (string): Shell command to execute (e.g., "npm run dev", "python -m http.server 8080")
|
||||
|
||||
**Returns:**
|
||||
```json
|
||||
{
|
||||
"status": "started",
|
||||
"port": 3010,
|
||||
"session_key": "A3X9"
|
||||
}
|
||||
```
|
||||
|
||||
### `stop`
|
||||
Stop a running development server session.
|
||||
|
||||
**Parameters:**
|
||||
- `session_key` (string): Session identifier
|
||||
|
||||
**Returns:**
|
||||
```json
|
||||
{
|
||||
"status": "stopped",
|
||||
"session_key": "A3X9"
|
||||
}
|
||||
```
|
||||
|
||||
### `status`
|
||||
Get status of one or all development server sessions.
|
||||
|
||||
**Parameters:**
|
||||
- `session_key` (optional string): Specific session to query, or omit for all sessions
|
||||
|
||||
**Returns:**
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"session_key": "B7K2",
|
||||
"port": 3010,
|
||||
"running": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `tail`
|
||||
Get stdout/stderr logs for a development server session.
|
||||
|
||||
**Parameters:**
|
||||
- `session_key` (string): Session identifier (e.g., "A3X9")
|
||||
|
||||
**Returns:**
|
||||
```json
|
||||
{
|
||||
"session_key": "A3X9",
|
||||
"stdout": "Server started on port 3010...",
|
||||
"stderr": ""
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Modules
|
||||
|
||||
- **port_allocator.rs** - Sequential port allocation from 3010 with free list
|
||||
- **log_buffer.rs** - Bounded 512KB ring buffer with Clone support
|
||||
- **server_entry.rs** - Process wrapper with async log capture
|
||||
- **manager.rs** - Shared state manager with auto-cleanup sweeper
|
||||
- **service.rs** - MCP service with tool definitions
|
||||
- **main.rs** - HTTP/SSE daemon server
|
||||
|
||||
### State Management
|
||||
|
||||
- Single `Arc<Manager>` shared across all client connections
|
||||
- Each connection gets a fresh `DevManagerService` instance
|
||||
- Mutex-protected HashMap for session storage
|
||||
- Background sweeper runs every 5 seconds to clean up idle sessions (>60s)
|
||||
|
||||
### Session Keys
|
||||
|
||||
- Auto-generated 4-character uppercase alphanumeric codes (e.g., "A3X9", "K7M2")
|
||||
- Guaranteed unique across active sessions
|
||||
- 1,679,616 possible combinations (36^4)
|
||||
|
||||
### Port Allocation
|
||||
|
||||
- Starts at 3010 and increments sequentially
|
||||
- Maintains free list for reused ports
|
||||
- Probes availability with TcpListener before assignment
|
||||
|
||||
### Log Capture
|
||||
|
||||
- Each server spawns two async tasks for stdout/stderr
|
||||
- Logs stored in bounded VecDeque with byte tracking
|
||||
- Oldest entries evicted when 512KB limit reached
|
||||
- Non-blocking reads with line buffering
|
||||
|
||||
## Testing Multi-Client Behavior
|
||||
|
||||
1. Start daemon: `./target/release/mcp-dev-manager`
|
||||
2. Connect Client A and start a server session
|
||||
3. Connect Client B and query status - should see Client A's session
|
||||
4. Client B can stop Client A's session
|
||||
5. All clients share the same session state
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
44
src/log_buffer.rs
Normal file
44
src/log_buffer.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const MAX_BYTES: usize = 512 * 1024;
|
||||
const MAX_TAIL_LINES: usize = 100;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LogBuffer {
|
||||
logs: VecDeque<String>,
|
||||
total_bytes: usize,
|
||||
}
|
||||
|
||||
impl LogBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
logs: VecDeque::new(),
|
||||
total_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, line: String) {
|
||||
let line_bytes = line.len();
|
||||
self.total_bytes += line_bytes;
|
||||
self.logs.push_back(line);
|
||||
|
||||
while self.total_bytes > MAX_BYTES && !self.logs.is_empty() {
|
||||
if let Some(old_line) = self.logs.pop_front() {
|
||||
self.total_bytes -= old_line.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tail(&self) -> (String, bool) {
|
||||
let len = self.logs.len();
|
||||
let start = len.saturating_sub(MAX_TAIL_LINES);
|
||||
|
||||
let mut out = String::new();
|
||||
for line in self.logs.iter().skip(start) {
|
||||
out.push_str(line);
|
||||
}
|
||||
|
||||
let truncated = len > MAX_TAIL_LINES;
|
||||
(out, truncated)
|
||||
}
|
||||
}
|
||||
35
src/main.rs
Normal file
35
src/main.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
mod log_buffer;
|
||||
mod manager;
|
||||
mod port_allocator;
|
||||
mod server_entry;
|
||||
mod service;
|
||||
|
||||
use manager::Manager;
|
||||
use rmcp::transport::sse_server::SseServer;
|
||||
use service::DevManagerService;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let manager = Arc::new(Manager::new());
|
||||
|
||||
let port = std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(3009);
|
||||
|
||||
let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
|
||||
|
||||
println!("MCP daemon listening on {}", bind);
|
||||
let server = SseServer::serve(bind).await?;
|
||||
|
||||
let cancel = server.with_service({
|
||||
let manager = Arc::clone(&manager);
|
||||
move || DevManagerService::new(Arc::clone(&manager))
|
||||
});
|
||||
|
||||
tokio::signal::ctrl_c().await?;
|
||||
cancel.cancel();
|
||||
Ok(())
|
||||
}
|
||||
279
src/manager.rs
Normal file
279
src/manager.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use crate::server_entry::ServerEntry;
|
||||
use rand::{thread_rng, Rng};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::process::Command;
|
||||
|
||||
type SessionKey = String;
|
||||
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
const RUNNING_IDLE_SECS: u64 = 60;
|
||||
const EXITED_RETENTION_SECS: u64 = 600;
|
||||
|
||||
fn generate_session_key() -> String {
|
||||
let mut rng = thread_rng();
|
||||
(0..4)
|
||||
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct ManagerInner {
|
||||
servers: HashMap<SessionKey, ServerEntry>,
|
||||
port_allocator: PortAllocator,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Manager {
|
||||
inner: Arc<Mutex<ManagerInner>>,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new() -> Self {
|
||||
let manager = Self {
|
||||
inner: Arc::new(Mutex::new(ManagerInner {
|
||||
servers: HashMap::new(),
|
||||
port_allocator: PortAllocator::new(3010),
|
||||
})),
|
||||
};
|
||||
|
||||
manager.start_sweeper();
|
||||
manager
|
||||
}
|
||||
|
||||
fn start_sweeper(&self) {
|
||||
let inner = self.inner.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let (to_stop, to_prune, _ports_to_free) = {
|
||||
let mut guard = match inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let now = Instant::now();
|
||||
let idle_threshold = Duration::from_secs(RUNNING_IDLE_SECS);
|
||||
let retention_threshold = Duration::from_secs(EXITED_RETENTION_SECS);
|
||||
|
||||
let mut to_stop = Vec::new();
|
||||
let mut to_prune = Vec::new();
|
||||
let mut ports_to_free = Vec::new();
|
||||
|
||||
for (key, entry) in guard.servers.iter_mut() {
|
||||
entry.poll_exit();
|
||||
|
||||
if let Some(exited_at) = entry.exited_at() {
|
||||
if entry.port != 0 {
|
||||
ports_to_free.push((key.clone(), entry.port));
|
||||
}
|
||||
if now.duration_since(exited_at) > retention_threshold {
|
||||
to_prune.push(key.clone());
|
||||
}
|
||||
} else if now.duration_since(entry.last_activity) > idle_threshold {
|
||||
to_stop.push(key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (key, port) in &ports_to_free {
|
||||
guard.port_allocator.free(*port);
|
||||
if let Some(entry) = guard.servers.get_mut(key) {
|
||||
entry.port = 0;
|
||||
}
|
||||
}
|
||||
|
||||
(to_stop, to_prune, ports_to_free)
|
||||
};
|
||||
|
||||
for key in to_stop {
|
||||
let entry_opt = {
|
||||
let mut guard = match inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => continue,
|
||||
};
|
||||
guard.servers.remove(&key)
|
||||
};
|
||||
|
||||
if let Some(mut entry) = entry_opt {
|
||||
let port = entry.port;
|
||||
let _ = entry.stop().await;
|
||||
entry.port = 0;
|
||||
|
||||
let mut guard = match inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => continue,
|
||||
};
|
||||
guard.port_allocator.free(port);
|
||||
guard.servers.insert(key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
for key in to_prune {
|
||||
let mut guard = match inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Some(entry) = guard.servers.remove(&key) {
|
||||
if entry.port != 0 {
|
||||
guard.port_allocator.free(entry.port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn start(&self, command: String) -> serde_json::Value {
|
||||
let (session_key, port) = {
|
||||
let mut guard = match self.inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return json!({"error": format!("Lock error: {}", e)}),
|
||||
};
|
||||
|
||||
let session_key = loop {
|
||||
let key = generate_session_key();
|
||||
if !guard.servers.contains_key(&key) {
|
||||
break key;
|
||||
}
|
||||
};
|
||||
|
||||
let port = match guard.port_allocator.allocate() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return json!({"error": format!("Port allocation failed: {}", e)}),
|
||||
};
|
||||
|
||||
(session_key, port)
|
||||
};
|
||||
|
||||
let mut cmd = if cfg!(target_os = "windows") {
|
||||
let mut c = Command::new("cmd");
|
||||
c.arg("/C").arg(&command);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("sh");
|
||||
c.arg("-c").arg(&command);
|
||||
c
|
||||
};
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let mut guard = self.inner.lock().unwrap();
|
||||
guard.port_allocator.free(port);
|
||||
return json!({"error": format!("Failed to spawn process: {}", e)});
|
||||
}
|
||||
};
|
||||
|
||||
let entry = ServerEntry::new(child, port);
|
||||
|
||||
{
|
||||
let mut guard = match self.inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return json!({"error": format!("Lock error: {}", e)}),
|
||||
};
|
||||
guard.servers.insert(session_key.clone(), entry);
|
||||
}
|
||||
|
||||
json!({
|
||||
"status": "started",
|
||||
"port": port,
|
||||
"session_key": session_key
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop(&self, session_key: String) -> serde_json::Value {
|
||||
let mut entry = {
|
||||
let mut guard = match self.inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return json!({"error": format!("Lock error: {}", e)}),
|
||||
};
|
||||
|
||||
match guard.servers.remove(&session_key) {
|
||||
Some(e) => e,
|
||||
None => return json!({"error": "Session not found"}),
|
||||
}
|
||||
};
|
||||
|
||||
let port = entry.port;
|
||||
entry.last_activity = Instant::now();
|
||||
|
||||
match entry.stop().await {
|
||||
Ok(_) => {
|
||||
entry.port = 0;
|
||||
|
||||
let mut guard = match self.inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return json!({"error": format!("Lock error: {}", e)}),
|
||||
};
|
||||
|
||||
guard.port_allocator.free(port);
|
||||
guard.servers.insert(session_key.clone(), entry);
|
||||
|
||||
json!({"status": "stopped", "session_key": session_key})
|
||||
}
|
||||
Err(e) => json!({"error": format!("Failed to stop server: {}", e)}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self, session_key: Option<String>) -> serde_json::Value {
|
||||
let mut guard = match self.inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return json!({"error": format!("Lock error: {}", e)}),
|
||||
};
|
||||
|
||||
if let Some(key) = session_key {
|
||||
if let Some(entry) = guard.servers.get_mut(&key) {
|
||||
entry.last_activity = Instant::now();
|
||||
let mut result = json!({
|
||||
"session_key": key,
|
||||
"running": entry.is_running()
|
||||
});
|
||||
if entry.port != 0 {
|
||||
result["port"] = json!(entry.port);
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
return json!({"error": "Session not found"});
|
||||
}
|
||||
}
|
||||
|
||||
let sessions: Vec<_> = guard.servers.iter_mut().map(|(key, entry)| {
|
||||
entry.last_activity = Instant::now();
|
||||
let mut result = json!({
|
||||
"session_key": key,
|
||||
"running": entry.is_running()
|
||||
});
|
||||
if entry.port != 0 {
|
||||
result["port"] = json!(entry.port);
|
||||
}
|
||||
result
|
||||
}).collect();
|
||||
|
||||
json!({"sessions": sessions})
|
||||
}
|
||||
|
||||
pub fn tail(&self, session_key: String) -> serde_json::Value {
|
||||
let mut guard = match self.inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return json!({"error": format!("Lock error: {}", e)}),
|
||||
};
|
||||
|
||||
if let Some(entry) = guard.servers.get_mut(&session_key) {
|
||||
entry.last_activity = Instant::now();
|
||||
let (stdout, stderr) = entry.get_logs();
|
||||
json!({
|
||||
"session_key": session_key,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr
|
||||
})
|
||||
} else {
|
||||
json!({"error": "Session not found"})
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/port_allocator.rs
Normal file
53
src/port_allocator.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::net::TcpListener;
|
||||
|
||||
pub struct PortAllocator {
|
||||
next_port: u16,
|
||||
free_list: VecDeque<u16>,
|
||||
in_use: HashSet<u16>,
|
||||
}
|
||||
|
||||
impl PortAllocator {
|
||||
pub fn new(start_port: u16) -> Self {
|
||||
Self {
|
||||
next_port: start_port,
|
||||
free_list: VecDeque::new(),
|
||||
in_use: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allocate(&mut self) -> anyhow::Result<u16> {
|
||||
// Try reusing from free list first
|
||||
while let Some(port) = self.free_list.pop_front() {
|
||||
if self.is_available(port) {
|
||||
self.in_use.insert(port);
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate new port sequentially
|
||||
loop {
|
||||
if self.next_port == u16::MAX {
|
||||
anyhow::bail!("Port allocation overflow - no more ports available");
|
||||
}
|
||||
|
||||
let port = self.next_port;
|
||||
self.next_port += 1;
|
||||
|
||||
if !self.in_use.contains(&port) && self.is_available(port) {
|
||||
self.in_use.insert(port);
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn free(&mut self, port: u16) {
|
||||
if self.in_use.remove(&port) {
|
||||
self.free_list.push_back(port);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_available(&self, port: u16) -> bool {
|
||||
TcpListener::bind(("127.0.0.1", port)).is_ok()
|
||||
}
|
||||
}
|
||||
117
src/server_entry.rs
Normal file
117
src/server_entry.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use crate::log_buffer::LogBuffer;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Child;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
enum ProcessState {
|
||||
Running(Child),
|
||||
Exited { exited_at: Instant },
|
||||
}
|
||||
|
||||
pub struct ServerEntry {
|
||||
state: ProcessState,
|
||||
pub port: u16,
|
||||
pub last_activity: Instant,
|
||||
stdout_log: Arc<Mutex<LogBuffer>>,
|
||||
stderr_log: Arc<Mutex<LogBuffer>>,
|
||||
}
|
||||
|
||||
impl ServerEntry {
|
||||
pub fn new(mut child: Child, port: u16) -> Self {
|
||||
let stdout_log = Arc::new(Mutex::new(LogBuffer::new()));
|
||||
let stderr_log = Arc::new(Mutex::new(LogBuffer::new()));
|
||||
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let log = stdout_log.clone();
|
||||
tokio::spawn(async move {
|
||||
let reader = BufReader::new(stdout);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Ok(mut buffer) = log.lock() {
|
||||
buffer.push(line + "\n");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let log = stderr_log.clone();
|
||||
tokio::spawn(async move {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Ok(mut buffer) = log.lock() {
|
||||
buffer.push(line + "\n");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
state: ProcessState::Running(child),
|
||||
port,
|
||||
last_activity: Instant::now(),
|
||||
stdout_log,
|
||||
stderr_log,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll_exit(&mut self) -> bool {
|
||||
if let ProcessState::Running(child) = &mut self.state {
|
||||
if let Ok(Some(_)) = child.try_wait() {
|
||||
self.state = ProcessState::Exited {
|
||||
exited_at: Instant::now(),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
matches!(self.state, ProcessState::Running(_))
|
||||
}
|
||||
|
||||
pub fn exited_at(&self) -> Option<Instant> {
|
||||
if let ProcessState::Exited { exited_at } = self.state {
|
||||
Some(exited_at)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop(&mut self) -> anyhow::Result<()> {
|
||||
if let ProcessState::Running(child) = &mut self.state {
|
||||
child.kill().await?;
|
||||
|
||||
match timeout(Duration::from_secs(5), child.wait()).await {
|
||||
Ok(Ok(_)) => {},
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Err(_) => anyhow::bail!("Timeout waiting for process to exit"),
|
||||
}
|
||||
|
||||
self.state = ProcessState::Exited {
|
||||
exited_at: Instant::now(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_logs(&self) -> (String, String) {
|
||||
let stdout = if let Ok(buffer) = self.stdout_log.lock() {
|
||||
buffer.tail().0
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let stderr = if let Ok(buffer) = self.stderr_log.lock() {
|
||||
buffer.tail().0
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
(stdout, stderr)
|
||||
}
|
||||
}
|
||||
96
src/service.rs
Normal file
96
src/service.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use crate::manager::Manager;
|
||||
use rmcp::handler::server::tool::ToolRouter;
|
||||
use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::{
|
||||
CallToolResult, Content, Implementation, InitializeRequestParam, InitializeResult,
|
||||
ProtocolVersion, ServerCapabilities, ServerInfo,
|
||||
};
|
||||
use rmcp::service::RequestContext;
|
||||
use rmcp::{tool, tool_handler, tool_router, ErrorData, RoleServer, ServerHandler};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Clone)]
|
||||
struct StartRequest {
|
||||
command: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Clone)]
|
||||
struct StopRequest {
|
||||
session_key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Clone)]
|
||||
struct StatusRequest {
|
||||
session_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Clone)]
|
||||
struct TailRequest {
|
||||
session_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DevManagerService {
|
||||
manager: Arc<Manager>,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
impl DevManagerService {
|
||||
pub fn new(manager: Arc<Manager>) -> Self {
|
||||
Self {
|
||||
manager,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl DevManagerService {
|
||||
#[tool(description = "Start a development server. Returns auto-generated session key, port number and status.")]
|
||||
async fn start(&self, Parameters(req): Parameters<StartRequest>) -> Result<CallToolResult, ErrorData> {
|
||||
let result = self.manager.start(req.command).await;
|
||||
Ok(CallToolResult::success(vec![Content::text(result.to_string())]))
|
||||
}
|
||||
|
||||
#[tool(description = "Stop a running development server session.")]
|
||||
async fn stop(&self, Parameters(req): Parameters<StopRequest>) -> Result<CallToolResult, ErrorData> {
|
||||
let result = self.manager.stop(req.session_key).await;
|
||||
Ok(CallToolResult::success(vec![Content::text(result.to_string())]))
|
||||
}
|
||||
|
||||
#[tool(description = "Get status of one or all development server sessions.")]
|
||||
async fn status(&self, Parameters(req): Parameters<StatusRequest>) -> Result<CallToolResult, ErrorData> {
|
||||
let result = self.manager.status(req.session_key);
|
||||
Ok(CallToolResult::success(vec![Content::text(result.to_string())]))
|
||||
}
|
||||
|
||||
#[tool(description = "Get stdout/stderr logs for a development server session.")]
|
||||
async fn tail(&self, Parameters(req): Parameters<TailRequest>) -> Result<CallToolResult, ErrorData> {
|
||||
let result = self.manager.tail(req.session_key);
|
||||
Ok(CallToolResult::success(vec![Content::text(result.to_string())]))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for DevManagerService {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
protocol_version: ProtocolVersion::V_2024_11_05,
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
server_info: Implementation::from_build_env(),
|
||||
instructions: Some(
|
||||
"MCP Dev Server Manager - manages multiple development server sessions with automatic port allocation and log capture.".to_string()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn initialize(
|
||||
&self,
|
||||
_params: InitializeRequestParam,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<InitializeResult, ErrorData> {
|
||||
Ok(self.get_info())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user