Newer
Older
ExoLauncher / src-tauri / src / main.rs
@楽曲作りまくりおじさん 楽曲作りまくりおじさん 10 hours ago 14 KB Initial commit: ExoLauncher Desktop App with SSE and cleaned structure
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 mut 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(())
}