mod models;
mod db;
mod dispatcher;
use anyhow::Result;
use dotenvy::dotenv;
use rust_mcp_sdk::mcp_server::{ServerHandler, server_runtime, McpServerOptions};
use rust_mcp_sdk::McpServer;
use rust_mcp_sdk::schema::{
CallToolRequestParams, CallToolResult, ListToolsResult,
Tool, InitializeResult, Implementation, ServerCapabilities,
ToolInputSchema, ServerCapabilitiesTools, ContentBlock,
PaginatedRequestParams, RpcError, CallToolError, ProtocolVersion,
};
use rust_mcp_sdk::ToMcpServerHandler;
use rust_mcp_transport::{StdioTransport, TransportOptions};
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::db::Database;
use async_trait::async_trait;
use std::collections::BTreeMap;
use std::time::Duration;
use uuid::Uuid;
use crate::models::QueuedOutput;
use std::str::FromStr;
use axum::{
routing::{get, post},
extract::{State, Path},
Json, Router,
response::sse::{Event, Sse},
};
use tokio::sync::broadcast;
use futures_util::stream::{self, Stream};
use std::convert::Infallible;
use tower_http::cors::CorsLayer;
use serde::Deserialize;
#[derive(Clone)]
struct AppState {
db: Arc<Mutex<Database>>,
tx: broadcast::Sender<String>,
}
#[derive(Clone)]
struct ExoLauncherServer {
state: AppState,
}
#[async_trait]
impl ServerHandler for ExoLauncherServer {
async fn handle_list_tools_request(
&self,
_params: Option<PaginatedRequestParams>,
_runtime: Arc<dyn McpServer>,
) -> Result<ListToolsResult, RpcError> {
let mut enqueue_props = BTreeMap::new();
enqueue_props.insert("target".to_string(), serde_json::json!({ "type": "string", "description": "送信先(slack, webhook, etc.)" }).as_object().unwrap().clone());
enqueue_props.insert("content".to_string(), serde_json::json!({ "type": "string", "description": "送信内容" }).as_object().unwrap().clone());
enqueue_props.insert("metadata".to_string(), serde_json::json!({ "type": "string", "description": "追加情報(任意)" }).as_object().unwrap().clone());
let mut reject_props = BTreeMap::new();
reject_props.insert("id".to_string(), serde_json::json!({ "type": "string", "description": "却下するアイテムのID" }).as_object().unwrap().clone());
reject_props.insert("comment".to_string(), serde_json::json!({ "type": "string", "description": "却下理由・フィードバック" }).as_object().unwrap().clone());
let mut approve_props = BTreeMap::new();
approve_props.insert("id".to_string(), serde_json::json!({ "type": "string", "description": "承認するアイテムのID" }).as_object().unwrap().clone());
Ok(ListToolsResult {
tools: vec![
Tool {
name: "enqueue_output".to_string(),
description: Some("AIエージェントの出力を承認待ちスタックに追加します。".to_string()),
input_schema: ToolInputSchema::new(
vec!["target".to_string(), "content".to_string()],
Some(enqueue_props),
Some("object".to_string())
),
annotations: None,
execution: None,
icons: vec![],
meta: None,
output_schema: None,
title: None,
},
Tool {
name: "list_pending".to_string(),
description: Some("承認待ちの出力依頼を一覧表示します。".to_string()),
input_schema: ToolInputSchema::new(
vec![],
Some(BTreeMap::new()),
Some("object".to_string())
),
annotations: None,
execution: None,
icons: vec![],
meta: None,
output_schema: None,
title: None,
},
Tool {
name: "approve_dispatch".to_string(),
description: Some("指定したIDの出力を承認し、外部へ送信します。".to_string()),
input_schema: ToolInputSchema::new(
vec!["id".to_string()],
Some(approve_props),
Some("object".to_string())
),
annotations: None,
execution: None,
icons: vec![],
meta: None,
output_schema: None,
title: None,
},
Tool {
name: "reject_dispatch".to_string(),
description: Some("指定したIDの出力を却下し、コメントを残します。".to_string()),
input_schema: ToolInputSchema::new(
vec!["id".to_string(), "comment".to_string()],
Some(reject_props),
Some("object".to_string())
),
annotations: None,
execution: None,
icons: vec![],
meta: None,
output_schema: None,
title: None,
},
Tool {
name: "get_rejection_feedback".to_string(),
description: Some("却下されたアイテムと、人間からのフィードバックコメントを取得します。".to_string()),
input_schema: ToolInputSchema::new(
vec![],
Some(BTreeMap::new()),
Some("object".to_string())
),
annotations: None,
execution: None,
icons: vec![],
meta: None,
output_schema: None,
title: None,
},
],
next_cursor: None,
meta: None,
})
}
async fn handle_call_tool_request(
&self,
params: CallToolRequestParams,
_runtime: Arc<dyn McpServer>,
) -> Result<CallToolResult, CallToolError> {
let name = params.name.as_str();
let arguments = params.arguments.unwrap_or_default();
match name {
"enqueue_output" => {
let target = arguments["target"].as_str().unwrap_or("unknown");
let content = arguments["content"].as_str().unwrap_or("");
let metadata = arguments["metadata"].as_str();
let db = self.state.db.lock().await;
match db.enqueue(target, content, metadata) {
Ok(id) => {
let _ = self.state.tx.send("new_item".to_string());
Ok(CallToolResult {
content: vec![ContentBlock::text_content(format!("出力がスタックに追加されました。ID: {}", id))],
is_error: None,
meta: None,
structured_content: None,
})
},
Err(e) => Err(CallToolError::from_message(format!("Failed to enqueue: {}", e))),
}
}
"list_pending" => {
let db = self.state.db.lock().await;
match db.list_pending() {
Ok(items) => Ok(CallToolResult {
content: vec![ContentBlock::text_content(format!("承認待ちアイテム: {:?}", items))],
is_error: None,
meta: None,
structured_content: None,
}),
Err(e) => Err(CallToolError::from_message(format!("Failed to list: {}", e))),
}
}
"approve_dispatch" => {
let id_str = arguments["id"].as_str().unwrap_or("");
let id = Uuid::from_str(id_str).map_err(|_| CallToolError::from_message("Invalid ID format"))?;
let db = self.state.db.lock().await;
match db.approve(&id) {
Ok(_) => {
let _ = self.state.tx.send("status_changed".to_string());
Ok(CallToolResult {
content: vec![ContentBlock::text_content(format!("ID: {} が承認されました。送信を開始します。", id))],
is_error: None,
meta: None,
structured_content: None,
})
},
Err(e) => Err(CallToolError::from_message(format!("Failed to approve: {}", e))),
}
}
"reject_dispatch" => {
let id_str = arguments["id"].as_str().unwrap_or("");
let comment = arguments["comment"].as_str().unwrap_or("");
let id = Uuid::from_str(id_str).map_err(|_| CallToolError::from_message("Invalid ID format"))?;
let db = self.state.db.lock().await;
match db.reject(&id, comment) {
Ok(_) => {
let _ = self.state.tx.send("status_changed".to_string());
Ok(CallToolResult {
content: vec![ContentBlock::text_content(format!("ID: {} を却下しました。コメント: {}", id, comment))],
is_error: None,
meta: None,
structured_content: None,
})
},
Err(e) => Err(CallToolError::from_message(format!("Failed to reject: {}", e))),
}
}
"get_rejection_feedback" => {
let db = self.state.db.lock().await;
match db.get_rejected() {
Ok(items) => Ok(CallToolResult {
content: vec![ContentBlock::text_content(format!("却下されたアイテムとフィードバック: {:?}", items))],
is_error: None,
meta: None,
structured_content: None,
}),
Err(e) => Err(CallToolError::from_message(format!("Failed to get feedback: {}", e))),
}
}
_ => Err(CallToolError::unknown_tool(name.to_string())),
}
}
}
#[derive(Deserialize)]
struct ActionRequest {
comment: Option<String>,
}
async fn list_pending_handler(State(state): State<AppState>) -> Json<Vec<QueuedOutput>> {
let db = state.db.lock().await;
let items = db.list_pending().unwrap_or_default();
Json(items)
}
async fn approve_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Json<serde_json::Value> {
let db = state.db.lock().await;
match db.approve(&id) {
Ok(_) => {
let _ = state.tx.send("status_changed".to_string());
Json(serde_json::json!({ "status": "ok" }))
},
Err(e) => Json(serde_json::json!({ "status": "error", "message": e.to_string() })),
}
}
async fn reject_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<ActionRequest>,
) -> Json<serde_json::Value> {
let db = state.db.lock().await;
let comment = payload.comment.unwrap_or_default();
match db.reject(&id, &comment) {
Ok(_) => {
let _ = state.tx.send("status_changed".to_string());
Json(serde_json::json!({ "status": "ok" }))
},
Err(e) => Json(serde_json::json!({ "status": "error", "message": e.to_string() })),
}
}
async fn sse_handler(
State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let rx = state.tx.subscribe();
let stream = stream::unfold(rx, |mut rx| async move {
match rx.recv().await {
Ok(msg) => Some((Ok(Event::default().data(msg)), rx)),
Err(_) => None,
}
});
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv().ok();
let db_path = "exo_launcher.db";
let db = Arc::new(Mutex::new(Database::new(db_path)?));
let (tx, _rx) = broadcast::channel(100);
let state = AppState { db: Arc::clone(&db), tx };
let server_handler = ExoLauncherServer { state: state.clone() };
let server_details = InitializeResult {
server_info: Implementation {
name: "ExoLauncher".to_string(),
version: "0.1.0".to_string(),
title: Some("ExoLauncher MCP Server".to_string()),
description: Some("Gateway for agent-led external communication".to_string()),
icons: vec![],
website_url: None,
},
capabilities: ServerCapabilities {
tools: Some(ServerCapabilitiesTools { list_changed: None }),
logging: None,
prompts: None,
resources: None,
experimental: None,
completions: None,
tasks: None,
},
protocol_version: ProtocolVersion::V2024_11_05.to_string(),
instructions: None,
meta: None,
};
let transport = StdioTransport::new(TransportOptions {
timeout: Duration::from_secs(60),
})?;
let server = server_runtime::create_server(McpServerOptions {
server_details,
transport,
handler: server_handler.to_mcp_server_handler(),
task_store: None,
client_task_store: None,
message_observer: None,
});
println!("ExoLauncher MCP Server starting (stdio)...");
// HTTP API Server (Axum) を別タスクで起動
let app = Router::new()
.route("/api/pending", get(list_pending_handler))
.route("/api/approve/:id", post(approve_handler))
.route("/api/reject/:id", post(reject_handler))
.route("/api/sse", get(sse_handler))
.layer(CorsLayer::permissive())
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await?;
println!("Dashboard API Server listening on http://127.0.0.1:3001");
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
// MCP Server を別タスクで起動
tokio::spawn(async move {
if let Err(e) = McpServer::start(server).await {
eprintln!("MCP Server error: {}", e);
}
});
println!("ExoLauncher App starting...");
// Tauri を起動
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
Ok(())
}