use std::path::{Path, PathBuf};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{json, Value};
use tsafe_core::tooling_inventory::{
check_inventory, suggest_keys, SuggestKey, SuggestKeysRequest, ToolingInventoryError,
};
use crate::audit::{audit_call, CallStatus};
use crate::errors::{McpError, McpErrorKind};
use crate::session::Session;
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct InventoryCheckArgs {
root: Option<PathBuf>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct SuggestKeysArgs {
root: Option<PathBuf>,
namespace: String,
section: Option<String>,
key: String,
purpose: String,
consumer: String,
rotation: String,
reason: Option<String>,
apply: Option<bool>,
}
pub fn check(session: &Session, raw: Value) -> Result<Value, McpError> {
let args: InventoryCheckArgs = serde_json::from_value(raw).map_err(|err| {
McpError::new(
McpErrorKind::InvalidParams,
format!("invalid tsafe_inventory_check arguments: {err}"),
)
})?;
let root = resolve_root(session, args.root.as_deref())?;
let report = check_inventory(&root).map_err(map_inventory_error)?;
audit_call(
session,
"tsafe_inventory_check",
None,
Vec::new(),
None,
None,
if report.ok {
CallStatus::Success
} else {
CallStatus::Failure
},
if report.ok {
None
} else {
Some("tooling inventory check failed")
},
);
serde_json::to_value(report).map_err(|err| {
McpError::new(
McpErrorKind::InternalError,
format!("serialize inventory check report: {err}"),
)
})
}
pub fn suggest(session: &Session, raw: Value) -> Result<Value, McpError> {
let args: SuggestKeysArgs = serde_json::from_value(raw).map_err(|err| {
McpError::new(
McpErrorKind::InvalidParams,
format!("invalid tsafe_suggest_keys arguments: {err}"),
)
})?;
let root = resolve_root(session, args.root.as_deref())?;
let report = suggest_keys(
&root,
SuggestKeysRequest {
namespace: args.namespace,
source: "mcp".to_string(),
reason: args
.reason
.unwrap_or_else(|| "agent suggested missing secret slot".to_string()),
apply: args.apply.unwrap_or(true),
keys: vec![SuggestKey {
key: args.key,
purpose: args.purpose,
consumer: args.consumer,
rotation: args.rotation,
section: args.section,
}],
},
)
.map_err(map_inventory_error)?;
audit_call(
session,
"tsafe_suggest_keys",
None,
report.added_keys.clone(),
None,
None,
CallStatus::Success,
None,
);
serde_json::to_value(report).map_err(|err| {
McpError::new(
McpErrorKind::InternalError,
format!("serialize suggestion report: {err}"),
)
})
}
pub fn inventory_check_input_schema() -> Value {
Value::Object(crate::tools::schema::input_schema::<InventoryCheckArgs>())
}
pub fn suggest_keys_input_schema() -> Value {
Value::Object(crate::tools::schema::input_schema::<SuggestKeysArgs>())
}
pub fn inventory_check_schema() -> Value {
json!({
"name": "tsafe_inventory_check",
"description": "Validate the repo-local .tsafe/tooling/keys.ini secret-slot inventory. Values are never read or returned.",
"inputSchema": inventory_check_input_schema()
})
}
pub fn suggest_keys_schema() -> Value {
json!({
"name": "tsafe_suggest_keys",
"description": "Suggest a missing secret slot for the current repo. By default this appends metadata to .tsafe/tooling/keys.ini and writes a receipt; it never writes secret values to the vault.",
"inputSchema": suggest_keys_input_schema()
})
}
fn resolve_root(session: &Session, requested: Option<&Path>) -> Result<PathBuf, McpError> {
let root = requested
.map(Path::to_path_buf)
.or_else(|| session.workdir.clone())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let resolved = root.canonicalize().map_err(|err| {
McpError::new(
McpErrorKind::BadWorkdir,
format!(
"tooling root {} could not be resolved: {err}",
root.display()
),
)
})?;
if let Some(bound) = session.workdir.as_ref() {
let bound = bound.canonicalize().map_err(|err| {
McpError::new(
McpErrorKind::BadWorkdir,
format!(
"bound workdir {} could not be resolved: {err}",
bound.display()
),
)
})?;
if !resolved.starts_with(&bound) {
return Err(McpError::new(
McpErrorKind::ScopeWidening,
format!(
"tooling root {} is outside bound workdir {}",
resolved.display(),
bound.display()
),
));
}
}
Ok(resolved)
}
fn map_inventory_error(err: ToolingInventoryError) -> McpError {
match err {
ToolingInventoryError::InvalidInput(message) => {
McpError::new(McpErrorKind::InvalidParams, message)
}
ToolingInventoryError::Io(err) => {
McpError::new(McpErrorKind::InternalError, format!("tooling io: {err}"))
}
ToolingInventoryError::Json(err) => {
McpError::new(McpErrorKind::InternalError, format!("tooling json: {err}"))
}
}
}