mcp_execution_codegen/progressive/types.rs
1//! Types for progressive loading code generation.
2//!
3//! Defines data structures used during progressive code generation,
4//! where each tool is generated as a separate file.
5
6use serde::{Deserialize, Serialize};
7
8/// Context for rendering a single tool template.
9///
10/// Contains all data needed to generate one tool file in the
11/// progressive loading pattern.
12///
13/// # Examples
14///
15/// ```
16/// use mcp_execution_codegen::progressive::ToolContext;
17/// use serde_json::json;
18///
19/// let context = ToolContext {
20/// server_id: "github".to_string(),
21/// name: "create_issue".to_string(),
22/// name_literal: "create_issue".to_string(),
23/// server_id_literal: "github".to_string(),
24/// typescript_name: "createIssue".to_string(),
25/// description: "Creates a new issue".to_string(),
26/// input_schema: json!({"type": "object"}),
27/// properties: vec![],
28/// category: Some("issues".to_string()),
29/// keywords: Some("create,issue,new,bug".to_string()),
30/// short_description: "Create a new issue".to_string(),
31/// };
32///
33/// assert_eq!(context.server_id, "github");
34/// ```
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ToolContext {
37 /// MCP server identifier, sanitized for safe embedding in a `JSDoc` comment
38 pub server_id: String,
39 /// Original tool name (`snake_case`), sanitized for safe embedding in a `JSDoc` comment
40 pub name: String,
41 /// Original tool name escaped for safe embedding in a single-quoted TS string literal
42 pub name_literal: String,
43 /// Server identifier escaped for safe embedding in a single-quoted TS string literal
44 pub server_id_literal: String,
45 /// TypeScript-friendly name (camelCase), sanitized to a safe identifier
46 pub typescript_name: String,
47 /// Human-readable description
48 pub description: String,
49 /// JSON Schema for input parameters, with `description` fields sanitized
50 /// for safe interpolation into `JSDoc` block comments (see issue #102).
51 pub input_schema: serde_json::Value,
52 /// Extracted properties for template rendering
53 pub properties: Vec<PropertyInfo>,
54 /// Optional category for tool grouping
55 pub category: Option<String>,
56 /// Optional keywords for discovery via grep/search
57 pub keywords: Option<String>,
58 /// Short description for header comment. Always populated: `ProgressiveGenerator`'s only
59 /// constructor falls back to `description` when no categorization short description is
60 /// available, so `None` is not a state this type can represent.
61 pub short_description: String,
62}
63
64/// Information about a single parameter property.
65///
66/// Used in Handlebars templates to render parameter type definitions.
67///
68/// # Examples
69///
70/// ```
71/// use mcp_execution_codegen::progressive::PropertyInfo;
72///
73/// let prop = PropertyInfo {
74/// name: "title".to_string(),
75/// typescript_type: "string".to_string(),
76/// description: Some("Issue title".to_string()),
77/// required: true,
78/// };
79///
80/// assert_eq!(prop.name, "title");
81/// assert!(prop.required);
82/// ```
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct PropertyInfo {
85 /// Property name
86 pub name: String,
87 /// TypeScript type (e.g., "string", "number", "boolean")
88 pub typescript_type: String,
89 /// Optional description from schema
90 pub description: Option<String>,
91 /// Whether the property is required
92 pub required: bool,
93}
94
95/// Context for rendering the index.ts template.
96///
97/// Contains server-level metadata and list of all tools.
98///
99/// # Examples
100///
101/// ```
102/// use mcp_execution_codegen::progressive::IndexContext;
103///
104/// let context = IndexContext {
105/// server_name: "GitHub".to_string(),
106/// server_version: "1.0.0".to_string(),
107/// tool_count: 30,
108/// tools: vec![],
109/// categories: None,
110/// };
111///
112/// assert_eq!(context.tool_count, 30);
113/// ```
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct IndexContext {
116 /// Server name for documentation
117 pub server_name: String,
118 /// Server version
119 pub server_version: String,
120 /// Total number of tools
121 pub tool_count: usize,
122 /// List of tool summaries
123 pub tools: Vec<ToolSummary>,
124 /// Tools grouped by category (optional, for categorized generation)
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub categories: Option<Vec<CategoryInfo>>,
127}
128
129/// Summary of a tool for index file generation.
130///
131/// Lighter-weight than full `ToolContext`, used only for
132/// re-exports and documentation in index.ts.
133///
134/// # Examples
135///
136/// ```
137/// use mcp_execution_codegen::progressive::ToolSummary;
138///
139/// let summary = ToolSummary {
140/// typescript_name: "createIssue".to_string(),
141/// description: "Creates a new issue".to_string(),
142/// category: Some("issues".to_string()),
143/// keywords: Some("create,issue,new".to_string()),
144/// short_description: Some("Create a new issue".to_string()),
145/// };
146///
147/// assert_eq!(summary.typescript_name, "createIssue");
148/// ```
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ToolSummary {
151 /// TypeScript-friendly name (camelCase)
152 pub typescript_name: String,
153 /// Human-readable description
154 pub description: String,
155 /// Optional category for tool grouping
156 pub category: Option<String>,
157 /// Optional keywords for discovery via grep/search
158 pub keywords: Option<String>,
159 /// Optional short description for header comment
160 pub short_description: Option<String>,
161}
162
163/// Categorization metadata for a single tool.
164///
165/// Contains all categorization data from Claude's analysis.
166///
167/// # Examples
168///
169/// ```
170/// use mcp_execution_codegen::progressive::ToolCategorization;
171///
172/// let cat = ToolCategorization {
173/// category: "issues".to_string(),
174/// keywords: vec!["create".to_string(), "issue".to_string(), "new".to_string(), "bug".to_string()],
175/// short_description: "Create a new issue in a repository".to_string(),
176/// };
177///
178/// assert_eq!(cat.category, "issues");
179/// ```
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ToolCategorization {
182 /// Category for tool grouping
183 pub category: String,
184 /// Keywords for discovery via grep/search
185 pub keywords: Vec<String>,
186 /// Concise description for header comment
187 pub short_description: String,
188}
189
190/// Category information for grouped tool display in index.
191///
192/// Groups tools by category for organized documentation.
193///
194/// # Examples
195///
196/// ```
197/// use mcp_execution_codegen::progressive::{CategoryInfo, ToolSummary};
198///
199/// let category = CategoryInfo {
200/// name: "issues".to_string(),
201/// tools: vec![
202/// ToolSummary {
203/// typescript_name: "createIssue".to_string(),
204/// description: "Creates a new issue".to_string(),
205/// category: Some("issues".to_string()),
206/// keywords: Some("create,issue".to_string()),
207/// short_description: Some("Create issue".to_string()),
208/// },
209/// ],
210/// };
211///
212/// assert_eq!(category.name, "issues");
213/// ```
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct CategoryInfo {
216 /// Category name
217 pub name: String,
218 /// Tools in this category
219 pub tools: Vec<ToolSummary>,
220}
221
222/// Context for rendering the runtime bridge template.
223///
224/// The forbidden-char/forbidden-env-name/charset-pattern fields are rendered directly from
225/// `mcp_execution_core`'s canonical lists so the generated bridge's copies structurally
226/// cannot drift from the Rust source of truth — see [`BridgeContext::default`], the only way
227/// to construct one, which populates them from `mcp_execution_core::forbidden_chars`/
228/// `forbidden_env_names`/`forbidden_env_prefix`/`env_name_charset_pattern` rather than leaving
229/// them empty. This deliberately does *not* derive `Default`: an empty `forbidden_chars` would
230/// render a bridge whose `validateCommandString` accepts every shell metacharacter, and an
231/// empty `env_name_charset_pattern` would render `new RegExp('')`, which matches every string
232/// (fail-open on exactly the checks these exist to enforce) — so `Default` is hand-written to
233/// make "always populated" a property of the type rather than a convention callers must
234/// remember to uphold.
235///
236/// Those four fields are private with read-only accessors for the same reason: `pub` fields
237/// would let `BridgeContext { forbidden_chars: vec![], .. }` bypass the invariant entirely and
238/// still compile, silently reintroducing the fail-open state `Default` exists to prevent.
239/// `Deserialize` is intentionally not derived — nothing in this codebase deserializes a
240/// `BridgeContext` from external input, and doing so would need to re-validate non-emptiness
241/// rather than trust the wire data.
242///
243/// The remaining fields — the denial-of-service size/count ceilings
244/// (`mcp_execution_core::MAX_ARG_COUNT` and siblings) and `env_name_charset_desc` (the
245/// human-readable charset description used only in a rejection message's text, not in the
246/// enforcement regex above) — are plain `pub` fields: unlike an emptied list or pattern, a
247/// wrong value here cannot fail open — at worst it makes the rendered bridge reject configs it
248/// should accept (a wrong `MAX_*`), or emit a confusing-but-still-rejecting error message (a
249/// wrong `env_name_charset_desc`), never silently accept something it shouldn't — so the extra
250/// accessor/invariant machinery above would be pure ceremony here.
251///
252/// # Examples
253///
254/// ```
255/// use mcp_execution_codegen::progressive::BridgeContext;
256///
257/// let context = BridgeContext::default();
258/// assert!(!context.forbidden_chars().is_empty());
259/// assert!(context.forbidden_chars().contains(&";".to_string()));
260/// assert!(!context.forbidden_env_prefix().is_empty());
261/// assert!(!context.env_name_charset_pattern().is_empty());
262/// assert!(!context.env_name_charset_desc.is_empty());
263/// assert!(context.max_arg_count > 0);
264/// ```
265#[derive(Debug, Clone, Serialize)]
266pub struct BridgeContext {
267 /// Shell metacharacters forbidden in a command or argument string, each pre-escaped for
268 /// safe embedding inside a single-quoted TypeScript string literal.
269 forbidden_chars: Vec<String>,
270 /// Forbidden environment variable names (exact match).
271 forbidden_env_names: Vec<String>,
272 /// Environment-variable-name prefix rejected regardless of exact match (e.g. `DYLD_`).
273 forbidden_env_prefix: String,
274 /// POSIX/Windows environment-variable-name identifier charset, as an anchored JavaScript
275 /// `RegExp`-compatible pattern source (see `mcp_execution_core::env_name_charset_pattern`),
276 /// pre-escaped for safe embedding inside a single-quoted TypeScript string literal — same
277 /// treatment as `forbidden_chars`, and for the same reason: an unescaped pattern containing
278 /// a `'` or `\` would either break the generated `new RegExp('...')` call or silently change
279 /// what it matches.
280 env_name_charset_pattern: String,
281 /// Human-readable description of the charset above (`mcp_execution_core::env_name_charset_desc`,
282 /// e.g. `"[A-Za-z_][A-Za-z0-9_]*"`), pre-escaped like `env_name_charset_pattern` and
283 /// rendered into the bridge's own rejection message so that text isn't a second
284 /// hand-copied literal alongside the pattern.
285 pub env_name_charset_desc: String,
286 /// Maximum number of positional arguments (`mcp_execution_core::MAX_ARG_COUNT`).
287 pub max_arg_count: usize,
288 /// Maximum byte length for a command, argument, env-var name, or header name
289 /// (`mcp_execution_core::MAX_ARG_LEN`).
290 pub max_arg_len: usize,
291 /// Maximum number of environment variables (`mcp_execution_core::MAX_ENV_COUNT`).
292 pub max_env_count: usize,
293 /// Maximum byte length for a single environment variable value
294 /// (`mcp_execution_core::MAX_ENV_VALUE_LEN`).
295 pub max_env_value_len: usize,
296 /// Maximum byte length for the Http/Sse transport `url`
297 /// (`mcp_execution_core::MAX_URL_LEN`).
298 pub max_url_len: usize,
299 /// Maximum number of HTTP headers (`mcp_execution_core::MAX_HEADER_COUNT`).
300 pub max_header_count: usize,
301 /// Maximum byte length for a single HTTP header value
302 /// (`mcp_execution_core::MAX_HEADER_VALUE_LEN`).
303 pub max_header_value_len: usize,
304}
305
306impl BridgeContext {
307 /// Shell metacharacters forbidden in a command or argument string, each pre-escaped for
308 /// safe embedding inside a single-quoted TypeScript string literal. Never empty.
309 ///
310 /// # Examples
311 ///
312 /// ```
313 /// use mcp_execution_codegen::progressive::BridgeContext;
314 ///
315 /// assert!(!BridgeContext::default().forbidden_chars().is_empty());
316 /// ```
317 #[must_use]
318 pub fn forbidden_chars(&self) -> &[String] {
319 &self.forbidden_chars
320 }
321
322 /// Forbidden environment variable names (exact match). Never empty.
323 ///
324 /// # Examples
325 ///
326 /// ```
327 /// use mcp_execution_codegen::progressive::BridgeContext;
328 ///
329 /// assert!(!BridgeContext::default().forbidden_env_names().is_empty());
330 /// ```
331 #[must_use]
332 pub fn forbidden_env_names(&self) -> &[String] {
333 &self.forbidden_env_names
334 }
335
336 /// Environment-variable-name prefix rejected regardless of exact match (e.g. `DYLD_`).
337 /// Never empty.
338 ///
339 /// # Examples
340 ///
341 /// ```
342 /// use mcp_execution_codegen::progressive::BridgeContext;
343 ///
344 /// assert!(!BridgeContext::default().forbidden_env_prefix().is_empty());
345 /// ```
346 #[must_use]
347 pub fn forbidden_env_prefix(&self) -> &str {
348 &self.forbidden_env_prefix
349 }
350
351 /// POSIX/Windows environment-variable-name identifier charset, as an anchored JavaScript
352 /// `RegExp`-compatible pattern source. Never empty.
353 ///
354 /// # Examples
355 ///
356 /// ```
357 /// use mcp_execution_codegen::progressive::BridgeContext;
358 ///
359 /// assert!(!BridgeContext::default().env_name_charset_pattern().is_empty());
360 /// ```
361 #[must_use]
362 pub fn env_name_charset_pattern(&self) -> &str {
363 &self.env_name_charset_pattern
364 }
365}
366
367impl Default for BridgeContext {
368 /// Populates the forbidden-char/forbidden-env-name/charset-pattern fields directly from
369 /// `mcp_execution_core`'s canonical lists/constants, so `BridgeContext::default()` can
370 /// never render a bridge with an empty (fail-open) `FORBIDDEN_CHARS` or
371 /// `ENV_NAME_CHARSET_REGEX`. Each `forbidden_chars` entry and `env_name_charset_pattern`
372 /// itself are passed through `sanitize_ts_string_literal` (this crate's TS-string-literal
373 /// escaper) so they render as syntactically valid single-quoted TypeScript string literals
374 /// regardless of what the Rust source contains — critique #471/#467 S2: without this, a
375 /// future edit introducing a `'`/`\` into the Rust pattern would either break the generated
376 /// `new RegExp('...')` call or silently change what it matches.
377 fn default() -> Self {
378 Self {
379 forbidden_chars: mcp_execution_core::forbidden_chars()
380 .iter()
381 .map(|c| crate::progressive::generator::sanitize_ts_string_literal(&c.to_string()))
382 .collect(),
383 forbidden_env_names: mcp_execution_core::forbidden_env_names()
384 .iter()
385 .map(|&s| s.to_string())
386 .collect(),
387 forbidden_env_prefix: mcp_execution_core::forbidden_env_prefix().to_string(),
388 env_name_charset_pattern: crate::progressive::generator::sanitize_ts_string_literal(
389 mcp_execution_core::env_name_charset_pattern(),
390 ),
391 env_name_charset_desc: crate::progressive::generator::sanitize_ts_string_literal(
392 mcp_execution_core::env_name_charset_desc(),
393 ),
394 max_arg_count: mcp_execution_core::MAX_ARG_COUNT,
395 max_arg_len: mcp_execution_core::MAX_ARG_LEN,
396 max_env_count: mcp_execution_core::MAX_ENV_COUNT,
397 max_env_value_len: mcp_execution_core::MAX_ENV_VALUE_LEN,
398 max_url_len: mcp_execution_core::MAX_URL_LEN,
399 max_header_count: mcp_execution_core::MAX_HEADER_COUNT,
400 max_header_value_len: mcp_execution_core::MAX_HEADER_VALUE_LEN,
401 }
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use serde_json::json;
409
410 #[test]
411 fn test_tool_context() {
412 let context = ToolContext {
413 server_id: "github".to_string(),
414 name: "create_issue".to_string(),
415 name_literal: "create_issue".to_string(),
416 server_id_literal: "github".to_string(),
417 typescript_name: "createIssue".to_string(),
418 description: "Creates an issue".to_string(),
419 input_schema: json!({"type": "object"}),
420 properties: vec![],
421 category: Some("issues".to_string()),
422 keywords: Some("create,issue,new".to_string()),
423 short_description: "Create a new issue".to_string(),
424 };
425
426 assert_eq!(context.server_id, "github");
427 assert_eq!(context.name, "create_issue");
428 assert_eq!(context.typescript_name, "createIssue");
429 assert_eq!(context.category, Some("issues".to_string()));
430 assert_eq!(context.keywords, Some("create,issue,new".to_string()));
431 }
432
433 #[test]
434 fn test_property_info() {
435 let prop = PropertyInfo {
436 name: "title".to_string(),
437 typescript_type: "string".to_string(),
438 description: Some("Issue title".to_string()),
439 required: true,
440 };
441
442 assert_eq!(prop.name, "title");
443 assert_eq!(prop.typescript_type, "string");
444 assert!(prop.required);
445 }
446
447 #[test]
448 fn test_index_context() {
449 let context = IndexContext {
450 server_name: "GitHub".to_string(),
451 server_version: "1.0.0".to_string(),
452 tool_count: 5,
453 tools: vec![],
454 categories: None,
455 };
456
457 assert_eq!(context.server_name, "GitHub");
458 assert_eq!(context.tool_count, 5);
459 assert!(context.categories.is_none());
460 }
461
462 #[test]
463 fn test_tool_summary() {
464 let summary = ToolSummary {
465 typescript_name: "createIssue".to_string(),
466 description: "Creates an issue".to_string(),
467 category: Some("issues".to_string()),
468 keywords: Some("create,issue".to_string()),
469 short_description: Some("Create issue".to_string()),
470 };
471
472 assert_eq!(summary.typescript_name, "createIssue");
473 assert_eq!(summary.category, Some("issues".to_string()));
474 assert_eq!(summary.keywords, Some("create,issue".to_string()));
475 }
476
477 #[test]
478 fn test_bridge_context_default() {
479 let context = BridgeContext::default();
480 let _serialized = serde_json::to_string(&context).unwrap();
481
482 // #221 critique S2: `Default` must never render a fail-open (empty) forbidden-char
483 // list, since an empty `FORBIDDEN_CHARS` in the rendered bridge would make
484 // `validateCommandString` accept every shell metacharacter. Same reasoning applies to
485 // an empty `env_name_charset_pattern`: `new RegExp('')` matches every string.
486 assert!(!context.forbidden_chars().is_empty());
487 assert!(!context.forbidden_env_names().is_empty());
488 assert!(!context.forbidden_env_prefix().is_empty());
489 assert!(!context.env_name_charset_pattern().is_empty());
490
491 // #471: the DoS size/count ceilings must be populated from mcp_execution_core, not
492 // left at zero (which would reject every config, silently breaking every generated
493 // server rather than failing open — a different but still real correctness bug).
494 assert_eq!(context.max_arg_count, mcp_execution_core::MAX_ARG_COUNT);
495 assert_eq!(context.max_arg_len, mcp_execution_core::MAX_ARG_LEN);
496 assert_eq!(context.max_env_count, mcp_execution_core::MAX_ENV_COUNT);
497 assert_eq!(
498 context.max_env_value_len,
499 mcp_execution_core::MAX_ENV_VALUE_LEN
500 );
501 assert_eq!(context.max_url_len, mcp_execution_core::MAX_URL_LEN);
502 assert_eq!(
503 context.max_header_count,
504 mcp_execution_core::MAX_HEADER_COUNT
505 );
506 assert_eq!(
507 context.max_header_value_len,
508 mcp_execution_core::MAX_HEADER_VALUE_LEN
509 );
510 // Escaping is a no-op on this quote/backslash-free pattern, so the sanitized copy
511 // still equals the raw Rust source of truth.
512 assert_eq!(
513 context.env_name_charset_pattern(),
514 mcp_execution_core::env_name_charset_pattern()
515 );
516 assert_eq!(
517 context.env_name_charset_desc,
518 mcp_execution_core::env_name_charset_desc()
519 );
520 }
521}