Skip to main content

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}