mcp_execution_server/types.rs
1//! Type definitions for MCP server tools.
2//!
3//! This module defines all parameter and result types for the three main tools:
4//! - `introspect_server`: Connect to and introspect an MCP server
5//! - `save_categorized_tools`: Generate TypeScript files with categorization
6//! - `list_generated_servers`: List all servers with generated files
7
8use crate::clock::Clock;
9use chrono::{DateTime, Utc};
10use mcp_execution_core::{ServerConfig, ServerId};
11use mcp_execution_introspector::ServerInfo;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::path::PathBuf;
16use uuid::Uuid;
17
18// ============================================================================
19// introspect_server types
20// ============================================================================
21
22/// Parameters for introspecting an MCP server.
23///
24/// This type only ever builds a stdio [`ServerConfig`] (see
25/// `GeneratorService::introspect_server`, which calls `ServerConfig::builder().command(...)`
26/// and never sets a transport of `Http` or `Sse`). It must never gain a field capable of
27/// setting `ServerConfig`'s transport to `Http`/`Sse` (e.g. `url`, `http`, `sse`, `headers`)
28/// without SSRF allowlisting logic added alongside it: `ServerConfig::url`
29/// (`crates/mcp-core/src/server_config.rs`) documents that this crate does not apply SSRF
30/// allowlisting itself and expects a server-context embedder - which is exactly what
31/// `mcp-execution-server` is - to add its own before connecting. `tests::introspect_server_params_shape_is_pinned`
32/// pins the current field set so a silent addition fails to compile instead of merely
33/// widening the attack surface unnoticed.
34///
35/// # Examples
36///
37/// ```
38/// use mcp_execution_server::types::IntrospectServerParams;
39/// use std::collections::HashMap;
40///
41/// let params = IntrospectServerParams {
42/// server_id: "github".to_string(),
43/// command: "npx".to_string(),
44/// args: vec!["-y".to_string(), "@anthropic/mcp-server-github".to_string()],
45/// env: HashMap::new(),
46/// output_dir: None,
47/// connect_timeout_secs: None,
48/// discover_timeout_secs: None,
49/// };
50/// ```
51#[derive(Debug, Clone, Deserialize, JsonSchema)]
52pub struct IntrospectServerParams {
53 /// Unique identifier for the server (e.g., "github", "filesystem").
54 ///
55 /// Must be 1-64 lowercase letters, digits, or hyphens (see `validate_server_id`'s
56 /// `MAX_SERVER_ID_LENGTH`, mirrored here as a literal since schemars attributes cannot
57 /// reference a `const`).
58 #[schemars(length(max = 64), regex(pattern = r"^[a-z0-9-]+$"))]
59 pub server_id: String,
60
61 /// Command to start the server (e.g., "npx", "docker").
62 ///
63 /// Capped at `mcp_execution_core::MAX_ARG_LEN` (4096 bytes) at runtime; mirrored here as a
64 /// literal since schemars attributes cannot reference a `const`.
65 #[schemars(length(max = 4096))]
66 pub command: String,
67
68 /// Arguments to pass to the command.
69 ///
70 /// Capped at `mcp_execution_core::MAX_ARG_COUNT` (256) entries at runtime (enforced when
71 /// this becomes part of the `ServerConfig` built from these params); mirrored here as a
72 /// literal since schemars attributes cannot reference a `const`.
73 #[serde(default)]
74 #[schemars(length(max = 256))]
75 pub args: Vec<String>,
76
77 /// Environment variables for the server process.
78 ///
79 /// Capped at `mcp_execution_core::MAX_ENV_COUNT` (256) entries and
80 /// `mcp_execution_core::MAX_ENV_VALUE_LEN` (32KB) per value at runtime. No schemars
81 /// attribute is set here: schemars' `length` validation only emits `minProperties`/
82 /// `maxProperties` for `object`-typed schemas via the `map`/`set` traits it does not yet
83 /// support for derived struct fields, so a map-size constraint would be a silent no-op.
84 #[serde(default)]
85 pub env: HashMap<String, String>,
86
87 /// Custom output subdirectory, relative to `~/.claude/servers/{server_id}/`
88 /// (default: `~/.claude/servers/{server_id}` itself). Confined to that
89 /// directory: an absolute path, a `..` component, or a path that escapes it
90 /// via a symlink is rejected.
91 pub output_dir: Option<PathBuf>,
92
93 /// Connection (handshake) timeout in seconds, overriding the 30-second
94 /// default when set.
95 #[serde(default)]
96 pub connect_timeout_secs: Option<u64>,
97
98 /// Tool discovery timeout in seconds, overriding the 30-second default
99 /// when set.
100 #[serde(default)]
101 pub discover_timeout_secs: Option<u64>,
102}
103
104/// Result from introspecting an MCP server.
105///
106/// Contains tool metadata for Claude to categorize and a session ID
107/// for use with `save_categorized_tools`.
108#[derive(Debug, Clone, Serialize, JsonSchema)]
109pub struct IntrospectServerResult {
110 /// Server identifier
111 pub server_id: String,
112
113 /// Human-readable server name
114 pub server_name: String,
115
116 /// Number of tools discovered
117 pub tools_found: usize,
118
119 /// List of tools for categorization
120 pub tools: Vec<IntrospectedToolSummary>,
121
122 /// Session ID for `save_categorized_tools` call
123 pub session_id: Uuid,
124
125 /// Session expiration time (ISO 8601)
126 pub expires_at: DateTime<Utc>,
127}
128
129/// Summary of an introspected tool, returned to Claude for categorization.
130///
131/// Includes the tool name, description, and parameter names to help
132/// Claude understand the tool's purpose and assign appropriate categories.
133#[derive(Debug, Clone, Serialize, JsonSchema)]
134pub struct IntrospectedToolSummary {
135 /// Original tool name
136 pub name: String,
137
138 /// Tool description from server
139 pub description: String,
140
141 /// Parameter names for context
142 pub parameters: Vec<String>,
143}
144
145// ============================================================================
146// save_categorized_tools types
147// ============================================================================
148
149/// Parameters for saving categorized tools.
150///
151/// # Examples
152///
153/// ```
154/// use mcp_execution_server::types::{SaveCategorizedToolsParams, CategorizedTool};
155/// use uuid::Uuid;
156///
157/// let params = SaveCategorizedToolsParams {
158/// session_id: Uuid::new_v4(),
159/// categorized_tools: vec![
160/// CategorizedTool {
161/// name: "create_issue".to_string(),
162/// category: "issues".to_string(),
163/// keywords: "create,issue,new,bug,feature".to_string(),
164/// short_description: "Create a new issue in a repository".to_string(),
165/// },
166/// ],
167/// };
168/// ```
169#[derive(Debug, Clone, Deserialize, JsonSchema)]
170pub struct SaveCategorizedToolsParams {
171 /// Session ID from `introspect_server` call
172 pub session_id: Uuid,
173
174 /// Tools with Claude's categorization.
175 ///
176 /// The session-specific cap enforced at runtime is `min(introspected tool count,
177 /// MAX_TOOL_FILES)`, tighter than the flat limit below in nearly all cases; `MAX_TOOL_FILES`
178 /// (`mcp_execution_skill`, 500) is still the absolute ceiling regardless of session, so it
179 /// is what's mirrored here as a literal (schemars attributes cannot reference a `const`).
180 #[schemars(length(max = 500))]
181 pub categorized_tools: Vec<CategorizedTool>,
182}
183
184/// A tool with categorization metadata from Claude.
185///
186/// Claude analyzes the tool's purpose and provides:
187/// - A category for grouping related tools
188/// - Keywords for discovery via grep/search
189/// - A concise description for file headers
190#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
191pub struct CategorizedTool {
192 /// Original tool name (must match introspected tool).
193 ///
194 /// Capped at `MAX_CATEGORIZED_TOOL_NAME_LEN` (128 bytes) at runtime; mirrored here as a
195 /// literal since schemars attributes cannot reference a `const`. Note: JSON Schema's
196 /// `maxLength` counts Unicode code points, not bytes, so the two bounds only coincide
197 /// exactly for ASCII input — for legitimate multi-byte UTF-8 text, the runtime byte check
198 /// can reject a string the declared schema would still accept (never the reverse), since
199 /// bytes-per-char >= 1 (issue #198 M2).
200 #[schemars(length(max = 128))]
201 pub name: String,
202
203 /// Category assigned by Claude (e.g., "issues", "repos", "users").
204 ///
205 /// Capped at `MAX_CATEGORY_LEN` (100 bytes) at runtime; mirrored here as a literal since
206 /// schemars attributes cannot reference a `const`. See [`CategorizedTool::name`]'s doc
207 /// comment for the bytes-vs-characters caveat.
208 #[schemars(length(max = 100))]
209 pub category: String,
210
211 /// Comma-separated keywords for discovery.
212 ///
213 /// Capped at `MAX_KEYWORDS_LEN` (500 bytes) at runtime; mirrored here as a literal since
214 /// schemars attributes cannot reference a `const`. See [`CategorizedTool::name`]'s doc
215 /// comment for the bytes-vs-characters caveat.
216 #[schemars(length(max = 500))]
217 pub keywords: String,
218
219 /// Concise description (max 80 chars) for header comment.
220 ///
221 /// Capped at `MAX_SHORT_DESCRIPTION_LEN` (320 bytes — 4x the 80-char target, headroom for
222 /// multi-byte UTF-8) at runtime; mirrored here as a literal since schemars attributes
223 /// cannot reference a `const`. See [`CategorizedTool::name`]'s doc comment for the
224 /// bytes-vs-characters caveat.
225 #[schemars(length(max = 320))]
226 pub short_description: String,
227}
228
229/// Result from saving categorized tools.
230///
231/// Reports success status, number of files generated, and any errors
232/// that occurred during generation.
233#[derive(Debug, Clone, Serialize, JsonSchema)]
234pub struct SaveCategorizedToolsResult {
235 /// Whether generation succeeded
236 pub success: bool,
237
238 /// Number of TypeScript files created
239 pub files_generated: usize,
240
241 /// Directory where files were written
242 pub output_dir: String,
243
244 /// Count of tools per category
245 pub categories: HashMap<String, usize>,
246
247 /// Any tools that failed to generate
248 #[serde(skip_serializing_if = "Vec::is_empty")]
249 pub errors: Vec<ToolGenerationError>,
250}
251
252/// Error that occurred while generating a specific tool.
253#[derive(Debug, Clone, Serialize, JsonSchema)]
254pub struct ToolGenerationError {
255 /// Name of the tool that failed
256 pub tool_name: String,
257
258 /// Error message
259 pub error: String,
260}
261
262// ============================================================================
263// list_generated_servers types
264// ============================================================================
265
266/// Parameters for listing generated servers.
267#[derive(Debug, Clone, Deserialize, JsonSchema)]
268pub struct ListGeneratedServersParams {
269 /// Base directory to scan, relative to `~/.claude/servers`
270 /// (default: `~/.claude/servers` itself). Confined to that directory: an
271 /// absolute path, a `..` component, or a path that escapes it via a
272 /// symlink is rejected.
273 pub base_dir: Option<String>,
274}
275
276/// Result from listing generated servers.
277#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
278pub struct ListGeneratedServersResult {
279 /// List of servers with generated files
280 pub servers: Vec<GeneratedServerInfo>,
281
282 /// Total number of servers found
283 pub total_servers: usize,
284}
285
286/// Information about a server with generated progressive loading files.
287#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
288pub struct GeneratedServerInfo {
289 /// Server identifier
290 pub id: String,
291
292 /// Number of tool files (excluding runtime)
293 pub tool_count: usize,
294
295 /// Last generation timestamp
296 pub generated_at: Option<DateTime<Utc>>,
297
298 /// Directory path
299 pub output_dir: String,
300}
301
302// ============================================================================
303// State management types
304// ============================================================================
305
306/// Pending generation session.
307///
308/// Stores introspection data between `introspect_server` and
309/// `save_categorized_tools` calls.
310#[derive(Debug, Clone)]
311pub struct PendingGeneration {
312 /// Server identifier
313 pub server_id: ServerId,
314
315 /// Full server introspection data
316 pub server_info: ServerInfo,
317
318 /// Server configuration for regeneration if needed
319 pub config: ServerConfig,
320
321 /// Caller-supplied `output_dir` override from `introspect_server`, exactly as received
322 /// (already validated as syntactically safe - relative, no `..` - but not yet confinement-
323 /// checked against the filesystem). `None` when the default `{server_id}` directory was
324 /// used.
325 ///
326 /// `save_categorized_tools` derives the real export target fresh from this and
327 /// [`Self::server_id`] immediately before writing anything - see
328 /// `crate::output_dir::resolve_output_dir`.
329 pub output_dir_override: Option<PathBuf>,
330
331 /// Session creation time
332 pub created_at: DateTime<Utc>,
333
334 /// Session expiration time (30 minutes default)
335 pub expires_at: DateTime<Utc>,
336}
337
338impl PendingGeneration {
339 /// Default session timeout: 30 minutes.
340 pub const DEFAULT_TIMEOUT_MINUTES: i64 = 30;
341
342 /// Creates a new pending generation session.
343 ///
344 /// The session's `created_at`/`expires_at` are derived from `clock.now()`,
345 /// so tests can inject a fake clock instead of rewinding `expires_at`
346 /// after construction. Production callers should pass [`SystemClock`](crate::clock::SystemClock).
347 ///
348 /// # Examples
349 ///
350 /// ```
351 /// use mcp_execution_server::types::PendingGeneration;
352 /// use mcp_execution_server::clock::SystemClock;
353 /// use mcp_execution_core::{ServerId, ServerConfig};
354 /// use mcp_execution_introspector::ServerInfo;
355 /// use std::path::PathBuf;
356 ///
357 /// # fn example(server_info: ServerInfo) {
358 /// let server_id = ServerId::new("github").unwrap();
359 /// let config = ServerConfig::builder()
360 /// .command("npx".to_string())
361 /// .arg("-y".to_string())
362 /// .arg("@anthropic/mcp-server-github".to_string())
363 /// .build()
364 /// .unwrap();
365 /// let pending = PendingGeneration::new(
366 /// server_id,
367 /// server_info,
368 /// config,
369 /// None,
370 /// &SystemClock,
371 /// );
372 /// # }
373 /// ```
374 #[must_use]
375 pub fn new(
376 server_id: ServerId,
377 server_info: ServerInfo,
378 config: ServerConfig,
379 output_dir_override: Option<PathBuf>,
380 clock: &dyn Clock,
381 ) -> Self {
382 let now = clock.now();
383 Self {
384 server_id,
385 server_info,
386 config,
387 output_dir_override,
388 created_at: now,
389 expires_at: now + chrono::Duration::minutes(Self::DEFAULT_TIMEOUT_MINUTES),
390 }
391 }
392
393 /// Checks if this session has expired, using `clock.now()` as the current time.
394 ///
395 /// # Examples
396 ///
397 /// ```
398 /// use mcp_execution_server::types::PendingGeneration;
399 /// use mcp_execution_server::clock::SystemClock;
400 /// # use mcp_execution_core::{ServerId, ServerConfig};
401 /// # use mcp_execution_introspector::ServerInfo;
402 ///
403 /// # fn example(server_info: ServerInfo) {
404 /// let pending = PendingGeneration::new(
405 /// ServerId::new("test").unwrap(),
406 /// server_info,
407 /// ServerConfig::builder().command("echo".to_string()).build().unwrap(),
408 /// None,
409 /// &SystemClock,
410 /// );
411 ///
412 /// assert!(!pending.is_expired(&SystemClock));
413 /// # }
414 /// ```
415 #[must_use]
416 pub fn is_expired(&self, clock: &dyn Clock) -> bool {
417 clock.now() > self.expires_at
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use crate::clock::{SystemClock, TestClock};
425
426 #[test]
427 fn test_pending_generation_not_expired() {
428 let pending = create_test_pending();
429 assert!(!pending.is_expired(&SystemClock));
430 }
431
432 #[test]
433 fn test_pending_generation_not_expired_at_exact_boundary() {
434 let clock = TestClock::new(Utc::now());
435 let pending = create_test_pending_with_clock(&clock);
436
437 // `is_expired` uses strict `>`, so the exact expiry instant is not expired.
438 clock.advance(chrono::Duration::minutes(
439 PendingGeneration::DEFAULT_TIMEOUT_MINUTES,
440 ));
441 assert!(!pending.is_expired(&clock));
442 }
443
444 #[test]
445 fn test_pending_generation_not_expired_one_second_before_boundary() {
446 let clock = TestClock::new(Utc::now());
447 let pending = create_test_pending_with_clock(&clock);
448
449 clock.advance(
450 chrono::Duration::minutes(PendingGeneration::DEFAULT_TIMEOUT_MINUTES)
451 - chrono::Duration::seconds(1),
452 );
453 assert!(!pending.is_expired(&clock));
454 }
455
456 #[test]
457 fn test_pending_generation_expired_one_second_after_boundary() {
458 let clock = TestClock::new(Utc::now());
459 let pending = create_test_pending_with_clock(&clock);
460
461 clock.advance(
462 chrono::Duration::minutes(PendingGeneration::DEFAULT_TIMEOUT_MINUTES)
463 + chrono::Duration::seconds(1),
464 );
465 assert!(pending.is_expired(&clock));
466 }
467
468 #[test]
469 fn test_categorized_tool_serialization() {
470 let tool = CategorizedTool {
471 name: "create_issue".to_string(),
472 category: "issues".to_string(),
473 keywords: "create,issue,new".to_string(),
474 short_description: "Create a new issue".to_string(),
475 };
476
477 let json = serde_json::to_string(&tool).unwrap();
478 let _deserialized: CategorizedTool = serde_json::from_str(&json).unwrap();
479 }
480
481 /// Regression guard for issue #209: `IntrospectServerParams` must never gain a field
482 /// capable of setting `ServerConfig`'s transport to `Http`/`Sse` (e.g. `url`, `http`,
483 /// `sse`, `headers`) without SSRF allowlisting logic added alongside it. Destructuring
484 /// with a struct pattern that names every current field, with no `..` rest pattern, means
485 /// Rust's exhaustiveness check fails this file to compile the moment a field is added or
486 /// removed - a stronger guarantee than a runtime assertion could give.
487 #[test]
488 fn introspect_server_params_shape_is_pinned() {
489 let params = IntrospectServerParams {
490 server_id: "test".to_string(),
491 command: "echo".to_string(),
492 args: vec![],
493 env: HashMap::new(),
494 output_dir: None,
495 connect_timeout_secs: None,
496 discover_timeout_secs: None,
497 };
498
499 // Fields that must never appear on this type without SSRF allowlisting logic: `url`,
500 // `http`, `sse`, `headers`.
501 let IntrospectServerParams {
502 server_id: _,
503 command: _,
504 args: _,
505 env: _,
506 output_dir: _,
507 connect_timeout_secs: _,
508 discover_timeout_secs: _,
509 } = params;
510 }
511
512 // ── schemars bounds (issue #205) ──────────────────────────────────────
513
514 #[test]
515 fn test_categorized_tool_schema_declares_length_bounds() {
516 use crate::service::{
517 MAX_CATEGORIZED_TOOL_NAME_LEN, MAX_CATEGORY_LEN, MAX_KEYWORDS_LEN,
518 MAX_SHORT_DESCRIPTION_LEN,
519 };
520
521 let schema = schemars::schema_for!(CategorizedTool);
522 let props = schema.get("properties").unwrap().as_object().unwrap();
523
524 // Asserted against the real runtime constants (not hardcoded literals), so bumping one
525 // without updating the matching `#[schemars(length(max = ..))]` literal fails this test
526 // instead of leaving the declared schema silently stale (issue #198 S3).
527 assert_eq!(props["name"]["maxLength"], MAX_CATEGORIZED_TOOL_NAME_LEN);
528 assert_eq!(props["category"]["maxLength"], MAX_CATEGORY_LEN);
529 assert_eq!(props["keywords"]["maxLength"], MAX_KEYWORDS_LEN);
530 assert_eq!(
531 props["short_description"]["maxLength"],
532 MAX_SHORT_DESCRIPTION_LEN
533 );
534 }
535
536 #[test]
537 fn test_save_categorized_tools_params_schema_declares_vec_length_bound() {
538 let schema = schemars::schema_for!(SaveCategorizedToolsParams);
539 let props = schema.get("properties").unwrap().as_object().unwrap();
540
541 assert_eq!(
542 props["categorized_tools"]["maxItems"],
543 mcp_execution_skill::MAX_TOOL_FILES
544 );
545 }
546
547 #[test]
548 fn test_introspect_server_params_schema_declares_server_id_bounds() {
549 let schema = schemars::schema_for!(IntrospectServerParams);
550 let props = schema.get("properties").unwrap().as_object().unwrap();
551
552 assert_eq!(
553 props["server_id"]["maxLength"],
554 mcp_execution_skill::MAX_SERVER_ID_LENGTH
555 );
556 assert_eq!(props["server_id"]["pattern"], "^[a-z0-9-]+$");
557 assert_eq!(props["args"]["maxItems"], mcp_execution_core::MAX_ARG_COUNT);
558 assert_eq!(
559 props["command"]["maxLength"],
560 mcp_execution_core::MAX_ARG_LEN
561 );
562 }
563
564 // Test helpers
565 fn create_test_pending() -> PendingGeneration {
566 create_test_pending_with_clock(&SystemClock)
567 }
568
569 fn create_test_pending_with_clock(clock: &dyn Clock) -> PendingGeneration {
570 use mcp_execution_core::ToolName;
571 use mcp_execution_introspector::{ServerCapabilities, ToolInfo};
572
573 let server_id = ServerId::new("test").unwrap();
574 let server_info = ServerInfo {
575 id: server_id.clone(),
576 name: "Test Server".to_string(),
577 version: "1.0.0".to_string(),
578 capabilities: ServerCapabilities {
579 supports_tools: true,
580 supports_resources: false,
581 supports_prompts: false,
582 },
583 tools: vec![ToolInfo {
584 name: ToolName::new("test_tool").unwrap(),
585 description: "Test tool description".to_string(),
586 input_schema: serde_json::json!({}),
587 output_schema: None,
588 }],
589 };
590 let config = ServerConfig::builder()
591 .command("echo".to_string())
592 .build()
593 .unwrap();
594
595 PendingGeneration::new(server_id, server_info, config, None, clock)
596 }
597}