Skip to main content

hecate_mcp_server/
lib.rs

1use symrs::{BoxExpr, Expr, Symbol};
2use hecate_entity::JobConfig;
3use hecate_entity::job::{self, JobStatus};
4use hecate_entity::job::{Entity as Job, JobScheduler};
5use log::{error, info};
6use migration::{ExprTrait, Migrator, MigratorTrait};
7use rmcp::handler::server::tool::{Parameters, ToolRouter};
8use rmcp::{
9    ErrorData as McpError, RoleServer, ServiceExt,
10    model::{
11        CallToolResult, Content, GetPromptRequestParam, GetPromptResult, ListPromptsResult,
12        PaginatedRequestParam, Prompt, PromptMessage, PromptMessageContent, PromptMessageRole,
13        ServerCapabilities, ServerInfo,
14    },
15    service::RequestContext,
16    tool, transport,
17};
18use rmcp::{tool_handler, tool_router};
19use schemars::JsonSchema;
20use sea_orm::prelude::DateTimeUtc;
21use sea_orm::{ActiveModelBehavior, Condition, DerivePartialModel, FromQueryResult};
22use sea_orm::{
23    ActiveModelTrait, ActiveValue::Set, Database, DatabaseConnection, EntityTrait, IntoSimpleExpr,
24    QueryFilter,
25};
26use serde::{Deserialize, Serialize};
27
28use std::collections::HashMap;
29pub use std::error::Error as StdError;
30
31trait ToJson {
32    fn to_json(&self) -> String;
33}
34
35impl<T: Serialize> ToJson for T {
36    fn to_json(&self) -> String {
37        serde_json::to_string(self).expect("serializable object")
38    }
39}
40
41#[derive(Clone)]
42pub struct HecateSimulator {
43    db: DatabaseConnection,
44    pub tool_router: ToolRouter<Self>,
45}
46
47#[derive(Debug, thiserror::Error)]
48pub enum HecateError {
49    #[error("couldn't get home dir")]
50    NoHomeDir,
51}
52
53// impl<E: StdError> From<ExecutorError<E>> for rmcp::Error {
54//     fn from(value: ExecutorError<E>) -> Self {
55//         rmcp::Error::internal_error(value.to_string(), None)
56//     }
57// }
58
59#[derive(JsonSchema, Deserialize)]
60struct GreetRequest {
61    /// Name of the person to greet
62    name: String,
63}
64
65#[derive(JsonSchema, Deserialize)]
66struct JobRequest {
67    job_id: i64,
68}
69
70/// A request to evaluate a mathematical expression
71/// Variables can be supplied to substitute the symbols with values
72#[derive(JsonSchema, Deserialize)]
73struct EvaluateExprRequest {
74    /// Mathematical expression
75    expr: String,
76
77    /// Concretized symbol values (optional)
78    vars: Option<HashMap<Symbol, BoxExpr>>,
79}
80
81#[tool_router]
82impl HecateSimulator {
83    pub fn db_connection(&self) -> DatabaseConnection {
84        self.db.clone()
85    }
86    pub async fn new() -> anyhow::Result<Self> {
87        info!("Initiating Hecate Job Manager");
88        let mut dir = dirs::home_dir().ok_or_else(|| HecateError::NoHomeDir)?;
89        dir.push(".hecate");
90        std::fs::create_dir_all(&dir)?;
91        let res = HecateSimulator {
92            db: Database::connect(format!("sqlite://{}/hecate.db?mode=rwc", dir.display())).await?,
93            tool_router: Self::tool_router(),
94        };
95
96        Migrator::up(&res.db, None).await?;
97
98        // Identify jobs that are directly managed by Hecate, without a scheduler, that were
99        // interrupted during the last execution, and tag them as such.
100        let db = res.db.clone();
101        tokio::spawn(async move {
102            let mut job = job::ActiveModel::new();
103            job.status = Set(JobStatus::Interupted);
104            let update_res = Job::update_many().set(job).filter(
105                Condition::all()
106                    .add(
107                        job::Column::Status
108                            .into_simple_expr()
109                            .in_tuples(JobStatus::unfinished_values()),
110                    )
111                    .add(job::Column::Scheduler.into_simple_expr().is_null()),
112            );
113
114            let update_res = update_res.exec(&db).await;
115            match update_res {
116                Ok(res) => {
117                    info!("Identified {} new interruped jobs", res.rows_affected);
118                }
119                Err(e) => {
120                    error!("Failed to identify interrupted jobs: {}", e);
121                }
122            }
123        });
124
125        // Start status poller for scheduler jobs
126        let db = res.db.clone();
127        tokio::spawn(async move {
128            let unfinished_scheduler_jobs = Job::find().filter(
129                Condition::all()
130                    .add(
131                        job::Column::Status
132                            .into_simple_expr()
133                            .in_tuples(JobStatus::unfinished_values()),
134                    )
135                    .add(job::Column::Scheduler.into_simple_expr().is_not_null()),
136            );
137
138            let unfinished_scheduler_jobs = unfinished_scheduler_jobs.all(&db).await;
139
140            match &unfinished_scheduler_jobs {
141                Ok(jobs) => {
142                    info!("Identified {} unfinished scheduler jobs", jobs.len());
143                }
144                Err(e) => {
145                    error!("Failed to identify unfinished scheduler jobs: {}", e);
146                    return;
147                }
148            }
149            let unfinished_scheduler_jobs = unfinished_scheduler_jobs.unwrap();
150
151            if unfinished_scheduler_jobs.is_empty() {
152                return;
153            }
154            info!(
155                "Starting status pollers for {} unfinished scheduler jobs",
156                unfinished_scheduler_jobs.len()
157            );
158
159            for job in unfinished_scheduler_jobs {
160                let db = db.clone();
161                tokio::spawn(async move {
162                    let job_id = job.id;
163                    let updated_job = job.clone().update_status(&db).await;
164                    match updated_job {
165                        Ok(job) => {
166                            info!("Updated status for job {}", job.id);
167                        }
168                        Err(e) => {
169                            error!("Failed to update status for job {job_id}: {e}");
170                            info!("Setting status of job {job_id} to `Failed`");
171                            match job.set_status(JobStatus::Failed, &db).await {
172                                Ok(job) => info!("Set status `Failed` on job {}", job.id),
173                                Err(e) => {
174                                    error!("Failed to set status `Failed` on job {job_id} : {e}")
175                                }
176                            }
177                        }
178                    }
179                });
180            }
181        });
182
183        Ok(res)
184    }
185    /// Gets the current weather
186    #[tool]
187    fn get_weather() -> String {
188        "Too hot".into()
189    }
190
191    /// Returns the creator's name
192    #[tool]
193    fn creator_name() -> String {
194        "It's a secret ! Just kidding, it's Lyss.".into()
195    }
196
197    /// Greets a person
198    #[tool]
199    fn greet(Parameters(GreetRequest { name }): Parameters<GreetRequest>) -> String {
200        format!("Hey there {name}!")
201    }
202
203    /// Evaluates a math expression
204    #[tool]
205    fn evaluate(
206        Parameters(EvaluateExprRequest { expr, vars }): Parameters<EvaluateExprRequest>,
207    ) -> Result<String, McpError> {
208        let expr: Box<dyn Expr> = expr.parse().map_err(|e| {
209            McpError::invalid_params(
210                format!("Expression couldn't be parsed : {e}"),
211                Some(serde_json::Value::String(expr)),
212            )
213        })?;
214        let res = expr.evaluate(vars);
215        
216        Ok(res.str())
217    }
218
219    #[tool(
220        description = "Submit a new simulation job. If no number of num_nodes is provided and mpi is set to true in the schema, the number of nodes will be set to the number of available compute nodes.
221        By default, don't use mpi when running locally, and don't set debug to true. Finally, make sure the cfl condition is respected.
222        Cluster execution requires a scheduler, a cluster_access_name and a cluster name.
223        This tool takes care of starting the workflow of the job, ie. compiling and running it.
224        If only one node of compute, don't use mpi.
225        "
226    )]
227    pub async fn create_job(
228        &self,
229        Parameters(job_input): Parameters<JobConfig>,
230    ) -> Result<CallToolResult, McpError> {
231        let job = Job::new(job_input, &self.db).await?;
232        let job_id = job.id;
233        let db = self.db.clone();
234
235        tokio::spawn(async move {
236            match job.run(&db).await {
237                Ok(_) => info!("Successfully initiated job {job_id}"),
238                Err(e) => error!("Failed to run job: {e}"),
239            }
240        });
241
242        Ok(CallToolResult::success(vec![Content::text(format!(
243            "id: {}",
244            job_id
245        ))]))
246    }
247
248    #[tool(
249        description = "Cancel a simulation job. For example, this can be useful when one wants to make some changes to the input schema, and then recreate a new job with the updated configuration."
250    )]
251    async fn cancel_job(
252        &self,
253        Parameters(JobRequest { job_id }): Parameters<JobRequest>,
254    ) -> Result<CallToolResult, McpError> {
255        let job = Job::find_by_id(job_id)
256            .one(&self.db)
257            .await
258            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
259        if job.is_none() {
260            return Err(McpError::invalid_request("job not found", None));
261        }
262        let job = job.unwrap();
263
264        if job.status.is_done() {
265            return Err(McpError::invalid_request(
266                "job already reached completion",
267                Some(
268                    serde_json::to_value(job.status).expect("all status variants are serializable"),
269                ),
270            ));
271        }
272        if job.status == JobStatus::Canceled {
273            return Err(McpError::invalid_request("job already canceled", None));
274        }
275
276        let mut job: job::ActiveModel = job.into();
277        job.status = Set(JobStatus::Canceled);
278        job.update(&self.db)
279            .await
280            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
281        Ok(CallToolResult::success(vec![Content::text(&format!(
282            "cancelled job {job_id}"
283        ))]))
284    }
285
286    #[tool(description = "List unfinished simulation jobs ids and status.")]
287    async fn list_unfinished_jobs(&self) -> Result<CallToolResult, McpError> {
288        #[derive(DerivePartialModel, FromQueryResult, Serialize)]
289        #[sea_orm(entity = "Job")]
290        struct JobIdAndStatus {
291            id: i64,
292            status: JobStatus,
293        }
294
295        let sim_jobs = Job::find()
296            .filter(
297                job::Column::Status
298                    .into_simple_expr()
299                    .is_in(JobStatus::unfinished_values()),
300            )
301            .into_partial_model::<JobIdAndStatus>()
302            .all(&self.db)
303            .await
304            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
305
306        Ok(CallToolResult::success(vec![Content::text(
307            sim_jobs.to_json(),
308        )]))
309    }
310
311    #[tool(
312        description = "Get partial information about a simulation job (excluding source files and input schema)"
313    )]
314    pub async fn sim_job_info(
315        &self,
316        Parameters(JobRequest { job_id }): Parameters<JobRequest>,
317    ) -> Result<CallToolResult, McpError> {
318        #[derive(FromQueryResult, Serialize, DerivePartialModel)]
319        #[sea_orm(entity = "Job")]
320        struct JobInfo {
321            pub id: i64,
322            pub name: String,
323            pub created_at: DateTimeUtc,
324            // pub schema: Json,
325            // pub code: String,
326            // pub code_filename: Option<String>,
327            // pub cmakelists: Option<String>,
328            pub status: JobStatus,
329            pub compiler: Option<String>,
330            pub cluster_access_name: Option<String>,
331            pub scheduler: Option<JobScheduler>,
332            pub cluster: Option<String>,
333            pub queue: Option<String>,
334            pub num_nodes: Option<i32>,
335            pub remote_job_id: Option<String>,
336        }
337        let job_info = Job::find_by_id(job_id)
338            .into_partial_model::<JobInfo>()
339            .one(&self.db)
340            .await
341            .map_err(|e| McpError::internal_error(e.to_string(), None))?
342            .ok_or_else(|| McpError::resource_not_found("Job not found", None))?;
343
344        Ok(CallToolResult::success(vec![Content::text(
345            job_info.to_json(),
346        )]))
347    }
348
349    #[tool(description = "Get full information about a simulation job (including source files)")]
350    pub async fn sim_job_full_info(
351        &self,
352        Parameters(JobRequest { job_id }): Parameters<JobRequest>,
353    ) -> Result<CallToolResult, McpError> {
354        let job = Job::find_by_id(job_id)
355            .one(&self.db)
356            .await
357            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
358
359        Ok(CallToolResult::success(vec![Content::text(job.to_json())]))
360    }
361}
362
363#[tool_handler]
364impl rmcp::ServerHandler for HecateSimulator {
365    fn get_info(&self) -> ServerInfo {
366        ServerInfo {
367            instructions: Some("Hecate Simulation Service".into()),
368            capabilities: ServerCapabilities::builder()
369                .enable_tools()
370                .enable_prompts()
371                .enable_resources()
372                .build(),
373            ..Default::default()
374        }
375    }
376
377    async fn get_prompt(
378        &self,
379        GetPromptRequestParam { name, arguments: _ }: GetPromptRequestParam,
380        _context: RequestContext<RoleServer>,
381    ) -> Result<GetPromptResult, McpError> {
382        match name.as_str() {
383            "system_prompt" => Ok(GetPromptResult {
384                description: Some("This is the system prompt for Hecate.".into()),
385                messages: vec![PromptMessage {
386                    role: PromptMessageRole::User,
387                    content: PromptMessageContent::text(
388                        "The laplacian operator is available as laplacian. For instance laplacian u is laplacian * u. Derivatives are written with either diff(f, t, 2) or d^2(f)/dt^2. You can also use rounded d which might be better when relevant.",
389                    ),
390                }],
391            }),
392            _ => Err(McpError::invalid_params("prompt not found", None)),
393        }
394    }
395
396    async fn list_prompts(
397        &self,
398        _request: Option<PaginatedRequestParam>,
399        _context: RequestContext<RoleServer>,
400    ) -> Result<ListPromptsResult, McpError> {
401        Ok(ListPromptsResult {
402            next_cursor: None,
403            prompts: vec![Prompt::new(
404                "system_prompt",
405                Some("This is the system prompt for Hecate."),
406                None,
407            )],
408        })
409    }
410}
411
412pub async fn serve() -> anyhow::Result<()> {
413    let service = HecateSimulator::new()
414        .await?
415        .serve(transport::stdio())
416        .await
417        .inspect_err(|e| {
418            println!("Error starting server: {e}");
419        })?;
420    service.waiting().await?;
421
422    Ok(())
423}