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:
Louis Knight-Webb
2025-10-30 11:26:09 +00:00
parent 24d9fa62b0
commit c6c68b5916
10 changed files with 2121 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

1318
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

14
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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())
}
}