use crate::clock::Clock;
use chrono::{DateTime, Utc};
use mcp_execution_core::{ServerConfig, ServerId};
use mcp_execution_introspector::ServerInfo;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use uuid::Uuid;
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct IntrospectServerParams {
#[schemars(length(max = 64), regex(pattern = r"^[a-z0-9-]+$"))]
pub server_id: String,
#[schemars(length(max = 4096))]
pub command: String,
#[serde(default)]
#[schemars(length(max = 256))]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
pub output_dir: Option<PathBuf>,
#[serde(default)]
pub connect_timeout_secs: Option<u64>,
#[serde(default)]
pub discover_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct IntrospectServerResult {
pub server_id: String,
pub server_name: String,
pub tools_found: usize,
pub tools: Vec<IntrospectedToolSummary>,
pub session_id: Uuid,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct IntrospectedToolSummary {
pub name: String,
pub description: String,
pub parameters: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct SaveCategorizedToolsParams {
pub session_id: Uuid,
#[schemars(length(max = 500))]
pub categorized_tools: Vec<CategorizedTool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CategorizedTool {
#[schemars(length(max = 128))]
pub name: String,
#[schemars(length(max = 100))]
pub category: String,
#[schemars(length(max = 500))]
pub keywords: String,
#[schemars(length(max = 320))]
pub short_description: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct SaveCategorizedToolsResult {
pub success: bool,
pub files_generated: usize,
pub output_dir: String,
pub categories: HashMap<String, usize>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<ToolGenerationError>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ToolGenerationError {
pub tool_name: String,
pub error: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct ListGeneratedServersParams {
pub base_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ListGeneratedServersResult {
pub servers: Vec<GeneratedServerInfo>,
pub total_servers: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GeneratedServerInfo {
pub id: String,
pub tool_count: usize,
pub generated_at: Option<DateTime<Utc>>,
pub output_dir: String,
}
#[derive(Debug, Clone)]
pub struct PendingGeneration {
pub server_id: ServerId,
pub server_info: ServerInfo,
pub config: ServerConfig,
pub output_dir_override: Option<PathBuf>,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl PendingGeneration {
pub const DEFAULT_TIMEOUT_MINUTES: i64 = 30;
#[must_use]
pub fn new(
server_id: ServerId,
server_info: ServerInfo,
config: ServerConfig,
output_dir_override: Option<PathBuf>,
clock: &dyn Clock,
) -> Self {
let now = clock.now();
Self {
server_id,
server_info,
config,
output_dir_override,
created_at: now,
expires_at: now + chrono::Duration::minutes(Self::DEFAULT_TIMEOUT_MINUTES),
}
}
#[must_use]
pub fn is_expired(&self, clock: &dyn Clock) -> bool {
clock.now() > self.expires_at
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::{SystemClock, TestClock};
#[test]
fn test_pending_generation_not_expired() {
let pending = create_test_pending();
assert!(!pending.is_expired(&SystemClock));
}
#[test]
fn test_pending_generation_not_expired_at_exact_boundary() {
let clock = TestClock::new(Utc::now());
let pending = create_test_pending_with_clock(&clock);
clock.advance(chrono::Duration::minutes(
PendingGeneration::DEFAULT_TIMEOUT_MINUTES,
));
assert!(!pending.is_expired(&clock));
}
#[test]
fn test_pending_generation_not_expired_one_second_before_boundary() {
let clock = TestClock::new(Utc::now());
let pending = create_test_pending_with_clock(&clock);
clock.advance(
chrono::Duration::minutes(PendingGeneration::DEFAULT_TIMEOUT_MINUTES)
- chrono::Duration::seconds(1),
);
assert!(!pending.is_expired(&clock));
}
#[test]
fn test_pending_generation_expired_one_second_after_boundary() {
let clock = TestClock::new(Utc::now());
let pending = create_test_pending_with_clock(&clock);
clock.advance(
chrono::Duration::minutes(PendingGeneration::DEFAULT_TIMEOUT_MINUTES)
+ chrono::Duration::seconds(1),
);
assert!(pending.is_expired(&clock));
}
#[test]
fn test_categorized_tool_serialization() {
let tool = CategorizedTool {
name: "create_issue".to_string(),
category: "issues".to_string(),
keywords: "create,issue,new".to_string(),
short_description: "Create a new issue".to_string(),
};
let json = serde_json::to_string(&tool).unwrap();
let _deserialized: CategorizedTool = serde_json::from_str(&json).unwrap();
}
#[test]
fn introspect_server_params_shape_is_pinned() {
let params = IntrospectServerParams {
server_id: "test".to_string(),
command: "echo".to_string(),
args: vec![],
env: HashMap::new(),
output_dir: None,
connect_timeout_secs: None,
discover_timeout_secs: None,
};
let IntrospectServerParams {
server_id: _,
command: _,
args: _,
env: _,
output_dir: _,
connect_timeout_secs: _,
discover_timeout_secs: _,
} = params;
}
#[test]
fn test_categorized_tool_schema_declares_length_bounds() {
use crate::service::{
MAX_CATEGORIZED_TOOL_NAME_LEN, MAX_CATEGORY_LEN, MAX_KEYWORDS_LEN,
MAX_SHORT_DESCRIPTION_LEN,
};
let schema = schemars::schema_for!(CategorizedTool);
let props = schema.get("properties").unwrap().as_object().unwrap();
assert_eq!(props["name"]["maxLength"], MAX_CATEGORIZED_TOOL_NAME_LEN);
assert_eq!(props["category"]["maxLength"], MAX_CATEGORY_LEN);
assert_eq!(props["keywords"]["maxLength"], MAX_KEYWORDS_LEN);
assert_eq!(
props["short_description"]["maxLength"],
MAX_SHORT_DESCRIPTION_LEN
);
}
#[test]
fn test_save_categorized_tools_params_schema_declares_vec_length_bound() {
let schema = schemars::schema_for!(SaveCategorizedToolsParams);
let props = schema.get("properties").unwrap().as_object().unwrap();
assert_eq!(
props["categorized_tools"]["maxItems"],
mcp_execution_skill::MAX_TOOL_FILES
);
}
#[test]
fn test_introspect_server_params_schema_declares_server_id_bounds() {
let schema = schemars::schema_for!(IntrospectServerParams);
let props = schema.get("properties").unwrap().as_object().unwrap();
assert_eq!(
props["server_id"]["maxLength"],
mcp_execution_skill::MAX_SERVER_ID_LENGTH
);
assert_eq!(props["server_id"]["pattern"], "^[a-z0-9-]+$");
assert_eq!(props["args"]["maxItems"], mcp_execution_core::MAX_ARG_COUNT);
assert_eq!(
props["command"]["maxLength"],
mcp_execution_core::MAX_ARG_LEN
);
}
fn create_test_pending() -> PendingGeneration {
create_test_pending_with_clock(&SystemClock)
}
fn create_test_pending_with_clock(clock: &dyn Clock) -> PendingGeneration {
use mcp_execution_core::ToolName;
use mcp_execution_introspector::{ServerCapabilities, ToolInfo};
let server_id = ServerId::new("test").unwrap();
let server_info = ServerInfo {
id: server_id.clone(),
name: "Test Server".to_string(),
version: "1.0.0".to_string(),
capabilities: ServerCapabilities {
supports_tools: true,
supports_resources: false,
supports_prompts: false,
},
tools: vec![ToolInfo {
name: ToolName::new("test_tool").unwrap(),
description: "Test tool description".to_string(),
input_schema: serde_json::json!({}),
output_schema: None,
}],
};
let config = ServerConfig::builder()
.command("echo".to_string())
.build()
.unwrap();
PendingGeneration::new(server_id, server_info, config, None, clock)
}
}