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). Each document gets a unique short code in format PREFIX-TYPE-NNNN (e.g., PROJ-V-0001). Parent documents should be referenced by their short code (e.g., PROJ-V-0001). Document type availability depends on current flight level configuration.",
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 db_path = metis_dir.join("metis.db");
let database = Database::new(db_path.to_string_lossy().as_ref()).map_err(|e| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to open database: {}", e),
))
})?;
let mut config_repo = database.configuration_repository().map_err(|e| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to access configuration repository: {}", e),
))
})?;
let flight_config = config_repo.get_flight_level_config().map_err(|e| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to load configuration: {}", e),
))
})?;
let enabled_types = flight_config.enabled_document_types();
if !enabled_types.contains(&doc_type) {
let available_types: Vec<String> =
enabled_types.iter().map(|t| t.to_string()).collect();
return Err(CallToolError::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"{} creation is disabled in current configuration ({} mode). Available document types: {}. To enable {}, use 'metis config set --preset full' or configure individually with 'metis config set --strategies true --initiatives true'",
doc_type,
flight_config.preset_name(),
available_types.join(", "),
doc_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 resolved_parent_id = self.parent_id.clone();
let config = DocumentCreationConfig {
title: self.title.clone(),
description: None,
parent_id: resolved_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_strategy_id = if flight_config.strategies_enabled {
resolved_parent_id.as_ref().ok_or_else(|| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Initiative requires a parent strategy short code in full configuration",
))
})?.clone()
} else {
"NULL".to_string()
};
creation_service
.create_initiative_with_config(config, &parent_strategy_id, &flight_config)
.await
.map_err(|e| CallToolError::new(e))?
}
DocumentType::Task => {
if let Some(initiative_id) = resolved_parent_id.as_ref() {
let strategy_id = if flight_config.strategies_enabled {
self.find_strategy_short_code_for_initiative(metis_dir, initiative_id)?
} else {
"NULL".to_string()
};
creation_service
.create_task_with_config(
config,
&strategy_id,
initiative_id,
&flight_config,
)
.await
.map_err(|e| CallToolError::new(e))?
} else if flight_config.initiatives_enabled {
return Err(CallToolError::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Task requires a parent initiative ID in {} configuration. Provide an initiative_id or create as a backlog item.", flight_config.preset_name()),
)));
} else {
creation_service
.create_task_with_config(config, "NULL", "NULL", &flight_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(),
"short_code": result.short_code,
"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_short_code_for_initiative(
&self,
metis_dir: &Path,
initiative_id: &str,
) -> Result<String, CallToolError> {
let db_path = metis_dir.join("metis.db");
let db = Database::new(db_path.to_str().unwrap()).map_err(|e| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Database error: {}", e),
))
})?;
let mut repo = db.repository().map_err(|e| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Repository error: {}", e),
))
})?;
let initiative = repo
.find_by_short_code(initiative_id)
.map_err(|e| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Database lookup error: {}", e),
))
})?
.ok_or_else(|| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Initiative '{}' not found in database", initiative_id),
))
})?;
let strategy_id = initiative.strategy_id.ok_or_else(|| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Initiative '{}' has no parent strategy", initiative_id),
))
})?;
let strategy = repo
.find_by_short_code(&strategy_id)
.map_err(|e| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Database lookup error: {}", e),
))
})?
.ok_or_else(|| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Strategy '{}' not found in database", strategy_id),
))
})?;
Ok(strategy.short_code)
}
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(())
}
}