Skip to content

Commit e47e613

Browse files
committed
feat: add MCP server mode (ash mcp)
Simple MCP server over stdio: - tools/list: returns all 58 tools - tools/call: executes directly on host - initialize: returns server info Usage: ash mcp # starts MCP server Claude Desktop config: {"mcpServers": {"ash": {"command": "ash", "args": ["mcp"]}}} Same binary works in any environment: - Local: ash mcp (direct execution) - Docker: docker run ... ash mcp - K8s: kubectl exec ... ash mcp
1 parent 555b0d9 commit e47e613

3 files changed

Lines changed: 162 additions & 0 deletions

File tree

src/bin/ash.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,9 @@ enum Commands {
313313
op: CustomToolOp,
314314
},
315315

316+
/// Start MCP server over stdio (for Claude Desktop, etc.)
317+
Mcp,
318+
316319
/// Daemon management (long-lived background process)
317320
Daemon {
318321
#[command(subcommand)]
@@ -966,6 +969,14 @@ async fn main() -> anyhow::Result<()> {
966969
}
967970
}
968971

972+
Commands::Mcp => {
973+
// Run MCP server over stdio
974+
let all_tools = tools::all_tools();
975+
let server = ash::mcp::McpServer::new(all_tools);
976+
server.run().await.map_err(|e| anyhow::anyhow!("MCP server error: {}", e))?;
977+
return Ok(());
978+
}
979+
969980
Commands::Daemon { op } => {
970981
match op {
971982
DaemonOp::Start { foreground } => {

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::pin::Pin;
99

1010
pub mod backend;
1111
pub mod daemon;
12+
pub mod mcp;
1213
pub mod tools;
1314

1415
/// Tool execution result

src/mcp.rs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
//! MCP Server implementation
2+
//!
3+
//! Speaks MCP protocol over stdio. All tools are executed directly.
4+
//! This is the simplest way to expose ash tools to any MCP client.
5+
6+
use serde::{Deserialize, Serialize};
7+
use serde_json::{json, Value};
8+
use std::io::{self, BufRead, Write};
9+
10+
use crate::{Tool, ToolResult};
11+
12+
/// MCP JSON-RPC request
13+
#[derive(Debug, Deserialize)]
14+
struct McpRequest {
15+
jsonrpc: String,
16+
id: Option<Value>,
17+
method: String,
18+
#[serde(default)]
19+
params: Value,
20+
}
21+
22+
/// MCP JSON-RPC response
23+
#[derive(Debug, Serialize)]
24+
struct McpResponse {
25+
jsonrpc: String,
26+
id: Value,
27+
#[serde(skip_serializing_if = "Option::is_none")]
28+
result: Option<Value>,
29+
#[serde(skip_serializing_if = "Option::is_none")]
30+
error: Option<McpError>,
31+
}
32+
33+
#[derive(Debug, Serialize)]
34+
struct McpError {
35+
code: i32,
36+
message: String,
37+
}
38+
39+
impl McpResponse {
40+
fn ok(id: Value, result: Value) -> Self {
41+
Self { jsonrpc: "2.0".into(), id, result: Some(result), error: None }
42+
}
43+
44+
fn err(id: Value, code: i32, message: impl Into<String>) -> Self {
45+
Self {
46+
jsonrpc: "2.0".into(),
47+
id,
48+
result: None,
49+
error: Some(McpError { code, message: message.into() })
50+
}
51+
}
52+
}
53+
54+
/// MCP Server - handles stdio communication
55+
pub struct McpServer {
56+
tools: Vec<Box<dyn Tool>>,
57+
}
58+
59+
impl McpServer {
60+
pub fn new(tools: Vec<Box<dyn Tool>>) -> Self {
61+
Self { tools }
62+
}
63+
64+
/// Run the server (blocking, reads from stdin)
65+
pub async fn run(&self) -> io::Result<()> {
66+
let stdin = io::stdin();
67+
let mut stdout = io::stdout();
68+
69+
for line in stdin.lock().lines() {
70+
let line = line?;
71+
if line.trim().is_empty() {
72+
continue;
73+
}
74+
75+
let response = self.handle_request(&line).await;
76+
let output = serde_json::to_string(&response)?;
77+
writeln!(stdout, "{}", output)?;
78+
stdout.flush()?;
79+
}
80+
81+
Ok(())
82+
}
83+
84+
async fn handle_request(&self, line: &str) -> McpResponse {
85+
let req: McpRequest = match serde_json::from_str(line) {
86+
Ok(r) => r,
87+
Err(e) => return McpResponse::err(Value::Null, -32700, format!("Parse error: {}", e)),
88+
};
89+
90+
let id = req.id.unwrap_or(Value::Null);
91+
92+
match req.method.as_str() {
93+
"initialize" => self.handle_initialize(id, req.params),
94+
"tools/list" => self.handle_tools_list(id),
95+
"tools/call" => self.handle_tools_call(id, req.params).await,
96+
"notifications/initialized" => McpResponse::ok(id, json!({})),
97+
_ => McpResponse::err(id, -32601, format!("Method not found: {}", req.method)),
98+
}
99+
}
100+
101+
fn handle_initialize(&self, id: Value, _params: Value) -> McpResponse {
102+
McpResponse::ok(id, json!({
103+
"protocolVersion": "2024-11-05",
104+
"capabilities": {
105+
"tools": {}
106+
},
107+
"serverInfo": {
108+
"name": "ash",
109+
"version": env!("CARGO_PKG_VERSION")
110+
}
111+
}))
112+
}
113+
114+
fn handle_tools_list(&self, id: Value) -> McpResponse {
115+
let tools: Vec<Value> = self.tools.iter().map(|t| {
116+
json!({
117+
"name": t.name(),
118+
"description": t.description(),
119+
"inputSchema": t.schema()
120+
})
121+
}).collect();
122+
123+
McpResponse::ok(id, json!({ "tools": tools }))
124+
}
125+
126+
async fn handle_tools_call(&self, id: Value, params: Value) -> McpResponse {
127+
let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
128+
let args = params.get("arguments").cloned().unwrap_or(json!({}));
129+
130+
// Find tool
131+
let tool = match self.tools.iter().find(|t| t.name() == name) {
132+
Some(t) => t,
133+
None => return McpResponse::err(id, -32602, format!("Unknown tool: {}", name)),
134+
};
135+
136+
// Execute
137+
let result = tool.execute(args).await;
138+
139+
// Convert to MCP format
140+
let content = vec![json!({
141+
"type": "text",
142+
"text": if result.success { result.output } else { result.error.unwrap_or_default() }
143+
})];
144+
145+
McpResponse::ok(id, json!({
146+
"content": content,
147+
"isError": !result.success
148+
}))
149+
}
150+
}

0 commit comments

Comments
 (0)