use metis_core::{
application::services::document::{creation::DocumentCreationConfig, DocumentCreationService},
domain::documents::types::DocumentType,
Application, Database,
};
use rust_mcp_sdk::{
macros::{mcp_tool, JsonSchema},
schema::{schema_utils::CallToolError, CallToolResult, TextContent},
};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::str::FromStr;
#[mcp_tool(
name = "create_document",
description = "Create a new Metis document (vision, strategy, initiative, task, adr). Parent documents should be referenced by their ID (kebab-case string)",
idempotent_hint = false,
destructive_hint = false,
open_world_hint = false,
read_only_hint = false
)]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct CreateDocumentTool {
pub project_path: String,
pub document_type: String,
pub title: String,
pub parent_id: Option<String>,
pub risk_level: Option<String>,
pub complexity: Option<String>,
pub stakeholders: Option<Vec<String>>,
pub decision_maker: Option<String>,
}
impl CreateDocumentTool {
pub async fn call_tool(&self) -> std::result::Result<CallToolResult, CallToolError> {
let metis_dir = Path::new(&self.project_path);
if !metis_dir.exists() || !metis_dir.is_dir() {
return Err(CallToolError::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"Metis workspace not found at {}. Run initialize_project first.",
metis_dir.display()
),
)));
}
let doc_type = DocumentType::from_str(&self.document_type).map_err(|_| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Invalid document type: {}", self.document_type),
))
})?;
let creation_service = DocumentCreationService::new(metis_dir);
let complexity = self
.complexity
.as_ref()
.map(|c| c.parse())
.transpose()
.map_err(|e| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Invalid complexity: {}", e),
))
})?;
let risk_level = self
.risk_level
.as_ref()
.map(|r| r.parse())
.transpose()
.map_err(|e| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Invalid risk level: {}", e),
))
})?;
let config = DocumentCreationConfig {
title: self.title.clone(),
description: None,
parent_id: self
.parent_id
.as_ref()
.map(|id| metis_core::domain::documents::types::DocumentId::from(id.clone())),
tags: vec![],
phase: None, complexity,
risk_level,
};
let result = match doc_type {
DocumentType::Vision => {
if self.parent_id.is_some() {
return Err(CallToolError::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Vision documents cannot have a parent",
)));
}
creation_service
.create_vision(config)
.await
.map_err(|e| CallToolError::new(e))?
}
DocumentType::Strategy => creation_service
.create_strategy(config)
.await
.map_err(|e| CallToolError::new(e))?,
DocumentType::Initiative => {
let parent_id = self.parent_id.as_ref().ok_or_else(|| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Initiative requires a parent strategy ID",
))
})?;
creation_service
.create_initiative(config, parent_id)
.await
.map_err(|e| CallToolError::new(e))?
}
DocumentType::Task => {
if let Some(initiative_id) = self.parent_id.as_ref() {
let strategy_id = self.find_strategy_id_for_initiative(metis_dir, initiative_id)?;
creation_service
.create_task(config, &strategy_id, initiative_id)
.await
.map_err(|e| CallToolError::new(e))?
} else {
creation_service
.create_backlog_item(config)
.await
.map_err(|e| CallToolError::new(e))?
}
}
DocumentType::Adr => creation_service
.create_adr(config)
.await
.map_err(|e| CallToolError::new(e))?,
};
self.sync_workspace(metis_dir).await?;
let response = serde_json::json!({
"success": true,
"document_id": result.document_id.to_string(),
"document_type": self.document_type,
"title": self.title,
"file_path": result.file_path.to_string_lossy(),
"parent_id": self.parent_id,
"auto_synced": true
});
Ok(CallToolResult::text_content(vec![TextContent::from(
serde_json::to_string_pretty(&response).map_err(CallToolError::new)?,
)]))
}
fn find_strategy_id_for_initiative(
&self,
metis_dir: &Path,
initiative_id: &str,
) -> Result<String, CallToolError> {
let strategies_dir = metis_dir.join("strategies");
if !strategies_dir.exists() {
return Err(CallToolError::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No strategies directory found",
)));
}
for entry in std::fs::read_dir(&strategies_dir).map_err(CallToolError::new)? {
let entry = entry.map_err(CallToolError::new)?;
let strategy_path = entry.path();
if strategy_path.is_dir() {
let initiatives_dir = strategy_path.join("initiatives").join(initiative_id);
if initiatives_dir.exists() && initiatives_dir.is_dir() {
if let Some(strategy_name) = strategy_path.file_name() {
if let Some(strategy_id) = strategy_name.to_str() {
return Ok(strategy_id.to_string());
}
}
}
}
}
Err(CallToolError::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Parent initiative '{}' not found", initiative_id),
)))
}
async fn sync_workspace(&self, metis_dir: &Path) -> Result<(), CallToolError> {
let db_path = metis_dir.join("metis.db");
let database = Database::new(db_path.to_str().unwrap()).map_err(|e| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to open database for sync: {}", e),
))
})?;
let app = Application::new(database);
app.sync_directory(metis_dir)
.await
.map_err(|e| CallToolError::new(e))?;
Ok(())
}
}