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#[derive(JsonSchema, Deserialize)]
60struct GreetRequest {
61 name: String,
63}
64
65#[derive(JsonSchema, Deserialize)]
66struct JobRequest {
67 job_id: i64,
68}
69
70#[derive(JsonSchema, Deserialize)]
73struct EvaluateExprRequest {
74 expr: String,
76
77 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 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 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 #[tool]
187 fn get_weather() -> String {
188 "Too hot".into()
189 }
190
191 #[tool]
193 fn creator_name() -> String {
194 "It's a secret ! Just kidding, it's Lyss.".into()
195 }
196
197 #[tool]
199 fn greet(Parameters(GreetRequest { name }): Parameters<GreetRequest>) -> String {
200 format!("Hey there {name}!")
201 }
202
203 #[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 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}