Skip to main content

turbomcp_cli/
new.rs

1//! New project command implementation.
2//!
3//! Creates new MCP server projects from templates with proper configuration
4//! for various deployment targets including Cloudflare Workers.
5
6use crate::cli::{NewArgs, ProjectTemplate};
7use crate::error::{CliError, CliResult};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
13const WORKER_VERSION: &str = "0.8";
14
15/// Execute the new project command.
16pub fn execute(args: &NewArgs) -> CliResult<()> {
17    // Determine output directory
18    let output_dir = args
19        .output
20        .clone()
21        .unwrap_or_else(|| PathBuf::from(&args.name));
22
23    // Check if directory already exists
24    if output_dir.exists() {
25        return Err(CliError::Other(format!(
26            "Directory '{}' already exists",
27            output_dir.display()
28        )));
29    }
30
31    println!("Creating new MCP server project '{}'...", args.name);
32    println!("  Template: {}", args.template);
33
34    // Create project directory
35    fs::create_dir_all(&output_dir)
36        .map_err(|e| CliError::Other(format!("Failed to create directory: {}", e)))?;
37
38    // Generate project files based on template
39    match args.template {
40        ProjectTemplate::Minimal => generate_minimal(args, &output_dir)?,
41        ProjectTemplate::Full => generate_full(args, &output_dir)?,
42        ProjectTemplate::CloudflareWorkers => generate_cloudflare_workers(args, &output_dir)?,
43        ProjectTemplate::CloudflareWorkersOauth => {
44            generate_cloudflare_workers_oauth(args, &output_dir)?
45        }
46        ProjectTemplate::CloudflareWorkersDurableObjects => {
47            generate_cloudflare_workers_do(args, &output_dir)?
48        }
49    }
50
51    // Initialize git repository if requested
52    if args.git {
53        init_git(&output_dir)?;
54    }
55
56    println!("\nProject created successfully!");
57    println!("\nNext steps:");
58    println!("  cd {}", output_dir.display());
59
60    match args.template {
61        ProjectTemplate::CloudflareWorkers
62        | ProjectTemplate::CloudflareWorkersOauth
63        | ProjectTemplate::CloudflareWorkersDurableObjects => {
64            println!("  npx wrangler dev    # Start local development");
65            println!("  npx wrangler deploy # Deploy to Cloudflare");
66        }
67        _ => {
68            println!("  cargo build         # Build the server");
69            println!("  cargo run           # Run the server");
70        }
71    }
72
73    Ok(())
74}
75
76/// Generate a minimal MCP server project.
77fn generate_minimal(args: &NewArgs, output_dir: &Path) -> CliResult<()> {
78    let description = args
79        .description
80        .as_deref()
81        .unwrap_or("A minimal MCP server");
82
83    // Cargo.toml
84    let cargo_toml = format!(
85        r#"[package]
86name = "{name}"
87version = "0.1.0"
88edition = "2024"
89description = "{description}"
90{author}
91
92[dependencies]
93turbomcp = "{sdk_version}"
94tokio = {{ version = "1", features = ["full"] }}
95serde = {{ version = "1", features = ["derive"] }}
96schemars = "1.2"
97"#,
98        name = args.name,
99        description = description,
100        sdk_version = SDK_VERSION,
101        author = args
102            .author
103            .as_ref()
104            .map(|a| format!("authors = [\"{}\"]", a))
105            .unwrap_or_default(),
106    );
107
108    // src/main.rs
109    let main_rs = format!(
110        r#"//! {description}
111
112use turbomcp::prelude::*;
113use serde::Deserialize;
114
115#[derive(Clone)]
116struct {struct_name};
117
118#[derive(Deserialize, schemars::JsonSchema)]
119struct HelloArgs {{
120    /// Name to greet
121    name: String,
122}}
123
124#[server(name = "{name}", version = "0.1.0")]
125impl {struct_name} {{
126    /// Say hello to someone
127    #[tool("Say hello to someone")]
128    async fn hello(&self, args: HelloArgs) -> String {{
129        format!("Hello, {{}}!", args.name)
130    }}
131}}
132
133#[tokio::main]
134async fn main() -> Result<(), Box<dyn std::error::Error>> {{
135    let server = {struct_name};
136    server.run_stdio().await?;
137    Ok(())
138}}
139"#,
140        description = description,
141        struct_name = to_struct_name(&args.name),
142        name = args.name,
143    );
144
145    // Write files
146    write_file(output_dir, "Cargo.toml", &cargo_toml)?;
147    fs::create_dir_all(output_dir.join("src"))?;
148    write_file(&output_dir.join("src"), "main.rs", &main_rs)?;
149
150    // .gitignore
151    write_file(output_dir, ".gitignore", "/target\n")?;
152
153    Ok(())
154}
155
156/// Generate a full-featured MCP server project.
157fn generate_full(args: &NewArgs, output_dir: &Path) -> CliResult<()> {
158    let description = args
159        .description
160        .as_deref()
161        .unwrap_or("A full-featured MCP server");
162
163    // Cargo.toml
164    let cargo_toml = format!(
165        r#"[package]
166name = "{name}"
167version = "0.1.0"
168edition = "2024"
169description = "{description}"
170{author}
171
172[dependencies]
173turbomcp = {{ version = "{sdk_version}", features = ["http", "auth"] }}
174tokio = {{ version = "1", features = ["full"] }}
175serde = {{ version = "1", features = ["derive"] }}
176serde_json = "1"
177schemars = "1.2"
178tracing = "0.1"
179tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
180"#,
181        name = args.name,
182        description = description,
183        sdk_version = SDK_VERSION,
184        author = args
185            .author
186            .as_ref()
187            .map(|a| format!("authors = [\"{}\"]", a))
188            .unwrap_or_default(),
189    );
190
191    // src/main.rs
192    let main_rs = format!(
193        r#"//! {description}
194
195use turbomcp::prelude::*;
196use serde::{{Deserialize, Serialize}};
197use tracing_subscriber::{{layer::SubscriberExt, util::SubscriberInitExt}};
198
199#[derive(Clone)]
200struct {struct_name} {{
201    config: ServerConfig,
202}}
203
204#[derive(Clone, Debug, Serialize, Deserialize)]
205struct ServerConfig {{
206    name: String,
207}}
208
209// Tool argument types
210#[derive(Deserialize, schemars::JsonSchema)]
211struct HelloArgs {{
212    /// Name to greet
213    name: String,
214}}
215
216#[derive(Deserialize, schemars::JsonSchema)]
217struct CalculateArgs {{
218    /// First number
219    a: f64,
220    /// Second number
221    b: f64,
222    /// Operation to perform
223    operation: String,
224}}
225
226// Prompt argument types
227#[derive(Deserialize, schemars::JsonSchema)]
228struct GreetingArgs {{
229    /// User's name
230    name: String,
231    /// Tone of greeting
232    tone: Option<String>,
233}}
234
235#[server(
236    name = "{name}",
237    version = "0.1.0",
238    description = "{description}"
239)]
240impl {struct_name} {{
241    /// Say hello to someone
242    #[tool("Say hello to someone")]
243    async fn hello(&self, args: HelloArgs) -> String {{
244        format!("Hello, {{}}!", args.name)
245    }}
246
247    /// Perform a calculation
248    #[tool("Perform basic arithmetic")]
249    async fn calculate(&self, args: CalculateArgs) -> Result<String, ToolError> {{
250        let result = match args.operation.as_str() {{
251            "add" => args.a + args.b,
252            "subtract" => args.a - args.b,
253            "multiply" => args.a * args.b,
254            "divide" => {{
255                if args.b == 0.0 {{
256                    return Err(ToolError::new("Cannot divide by zero"));
257                }}
258                args.a / args.b
259            }}
260            _ => return Err(ToolError::new(format!(
261                "Unknown operation: {{}}. Use: add, subtract, multiply, divide",
262                args.operation
263            ))),
264        }};
265        Ok(format!("{{}} {{}} {{}} = {{}}", args.a, args.operation, args.b, result))
266    }}
267
268    /// Server configuration
269    #[resource("config://server")]
270    async fn config(&self, _uri: String) -> ResourceResult {{
271        ResourceResult::json("config://server", &self.config)
272            .unwrap_or_else(|e| ResourceResult::text("config://server", format!("Error: {{}}", e)))
273    }}
274
275    /// Greeting prompt
276    #[prompt("Generate a greeting")]
277    async fn greeting(&self, args: GreetingArgs) -> PromptResult {{
278        let tone = args.tone.as_deref().unwrap_or("friendly");
279        PromptResult::user(format!(
280            "Generate a {{}} greeting for {{}}.",
281            tone,
282            args.name
283        ))
284    }}
285}}
286
287#[tokio::main]
288async fn main() -> Result<(), Box<dyn std::error::Error>> {{
289    // Initialize tracing
290    tracing_subscriber::registry()
291        .with(tracing_subscriber::EnvFilter::new(
292            std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
293        ))
294        .with(tracing_subscriber::fmt::layer())
295        .init();
296
297    let server = {struct_name} {{
298        config: ServerConfig {{
299            name: "{name}".to_string(),
300        }},
301    }};
302
303    // Choose transport based on environment
304    if std::env::var("MCP_HTTP").is_ok() {{
305        tracing::info!("Starting HTTP server on port 8080...");
306        server.run_http("0.0.0.0:8080").await?;
307    }} else {{
308        tracing::info!("Starting STDIO server...");
309        server.run_stdio().await?;
310    }}
311
312    Ok(())
313}}
314"#,
315        description = description,
316        struct_name = to_struct_name(&args.name),
317        name = args.name,
318    );
319
320    // Write files
321    write_file(output_dir, "Cargo.toml", &cargo_toml)?;
322    fs::create_dir_all(output_dir.join("src"))?;
323    write_file(&output_dir.join("src"), "main.rs", &main_rs)?;
324
325    // .gitignore
326    write_file(output_dir, ".gitignore", "/target\n")?;
327
328    Ok(())
329}
330
331/// Generate a Cloudflare Workers MCP server project.
332fn generate_cloudflare_workers(args: &NewArgs, output_dir: &Path) -> CliResult<()> {
333    let description = args
334        .description
335        .as_deref()
336        .unwrap_or("An MCP server for Cloudflare Workers");
337
338    // Cargo.toml
339    let cargo_toml = format!(
340        r#"[package]
341name = "{name}"
342version = "0.1.0"
343edition = "2024"
344description = "{description}"
345{author}
346
347[lib]
348crate-type = ["cdylib"]
349
350[dependencies]
351turbomcp-wasm = {{ version = "{sdk_version}", features = ["macros", "streamable"] }}
352worker = "{worker_version}"
353serde = {{ version = "1", features = ["derive"] }}
354serde_json = "1"
355schemars = "1.2"
356wasm-bindgen = "0.2"
357wasm-bindgen-futures = "0.4"
358console_error_panic_hook = "0.1"
359
360[profile.release]
361opt-level = "s"
362lto = true
363"#,
364        name = args.name,
365        description = description,
366        sdk_version = SDK_VERSION,
367        worker_version = WORKER_VERSION,
368        author = args
369            .author
370            .as_ref()
371            .map(|a| format!("authors = [\"{}\"]", a))
372            .unwrap_or_default(),
373    );
374
375    // wrangler.toml
376    let wrangler_toml = format!(
377        r#"name = "{name}"
378main = "build/worker/shim.mjs"
379compatibility_date = "2024-01-01"
380
381[build]
382command = "cargo install -q worker-build && worker-build --release"
383
384# Uncomment for KV storage
385# [[kv_namespaces]]
386# binding = "MY_KV"
387# id = "your-kv-namespace-id"
388
389# Uncomment for Durable Objects
390# [[durable_objects.bindings]]
391# name = "MCP_STATE"
392# class_name = "McpState"
393"#,
394        name = args.name,
395    );
396
397    // src/lib.rs
398    let lib_rs = format!(
399        r#"//! {description}
400
401use turbomcp_wasm::prelude::*;
402use serde::Deserialize;
403use worker::*;
404
405#[derive(Clone)]
406struct {struct_name};
407
408#[derive(Deserialize, schemars::JsonSchema)]
409struct HelloArgs {{
410    /// Name to greet
411    name: String,
412}}
413
414#[server(name = "{name}", version = "0.1.0")]
415impl {struct_name} {{
416    /// Say hello to someone
417    #[tool("Say hello to someone")]
418    async fn hello(&self, args: HelloArgs) -> String {{
419        format!("Hello, {{}}!", args.name)
420    }}
421
422    /// Get server status
423    #[tool("Check server health")]
424    async fn status(&self) -> String {{
425        "OK".to_string()
426    }}
427}}
428
429#[event(fetch)]
430async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {{
431    console_error_panic_hook::set_once();
432
433    let server = {struct_name};
434    let mcp = server.into_mcp_server();
435
436    mcp.handle(req).await
437}}
438"#,
439        description = description,
440        struct_name = to_struct_name(&args.name),
441        name = args.name,
442    );
443
444    // Write files
445    write_file(output_dir, "Cargo.toml", &cargo_toml)?;
446    write_file(output_dir, "wrangler.toml", &wrangler_toml)?;
447    fs::create_dir_all(output_dir.join("src"))?;
448    write_file(&output_dir.join("src"), "lib.rs", &lib_rs)?;
449
450    // .gitignore
451    write_file(output_dir, ".gitignore", "/target\n/build\n/node_modules\n")?;
452
453    Ok(())
454}
455
456/// Generate a Cloudflare Workers MCP server with OAuth 2.1.
457fn generate_cloudflare_workers_oauth(args: &NewArgs, output_dir: &Path) -> CliResult<()> {
458    let description = args
459        .description
460        .as_deref()
461        .unwrap_or("An MCP server for Cloudflare Workers with OAuth 2.1");
462
463    // Cargo.toml
464    let cargo_toml = format!(
465        r#"[package]
466name = "{name}"
467version = "0.1.0"
468edition = "2024"
469description = "{description}"
470{author}
471
472[lib]
473crate-type = ["cdylib"]
474
475[dependencies]
476turbomcp-wasm = {{ version = "{sdk_version}", features = ["macros", "streamable", "auth"] }}
477worker = "{worker_version}"
478serde = {{ version = "1", features = ["derive"] }}
479serde_json = "1"
480schemars = "1.2"
481wasm-bindgen = "0.2"
482wasm-bindgen-futures = "0.4"
483console_error_panic_hook = "0.1"
484
485[profile.release]
486opt-level = "s"
487lto = true
488"#,
489        name = args.name,
490        description = description,
491        sdk_version = SDK_VERSION,
492        worker_version = WORKER_VERSION,
493        author = args
494            .author
495            .as_ref()
496            .map(|a| format!("authors = [\"{}\"]", a))
497            .unwrap_or_default(),
498    );
499
500    // wrangler.toml
501    let wrangler_toml = format!(
502        r#"name = "{name}"
503main = "build/worker/shim.mjs"
504compatibility_date = "2024-01-01"
505
506[build]
507command = "cargo install -q worker-build && worker-build --release"
508
509# OAuth token storage
510[[kv_namespaces]]
511binding = "OAUTH_TOKENS"
512id = "your-kv-namespace-id"
513
514# Secrets (set via wrangler secret put)
515# JWT_SECRET - Secret for signing JWT tokens
516# OAUTH_CLIENT_SECRET - OAuth client secret
517"#,
518        name = args.name,
519    );
520
521    // src/lib.rs
522    let lib_rs = format!(
523        r#"//! {description}
524
525use turbomcp_wasm::prelude::*;
526use turbomcp_wasm::wasm_server::{{WithAuth, AuthExt}};
527use serde::Deserialize;
528use worker::*;
529use std::sync::Arc;
530
531#[derive(Clone)]
532struct {struct_name};
533
534#[derive(Deserialize, schemars::JsonSchema)]
535struct HelloArgs {{
536    /// Name to greet
537    name: String,
538}}
539
540#[server(name = "{name}", version = "0.1.0")]
541impl {struct_name} {{
542    /// Say hello to someone (requires authentication)
543    #[tool("Say hello to someone")]
544    async fn hello(&self, ctx: Arc<RequestContext>, args: HelloArgs) -> Result<String, ToolError> {{
545        // Check authentication
546        if !ctx.is_authenticated() {{
547            return Err(ToolError::new("Authentication required"));
548        }}
549
550        let user = ctx.user_id().unwrap_or("unknown");
551        Ok(format!("Hello, {{}}! (authenticated as {{}})", args.name, user))
552    }}
553
554    /// Get server status (public)
555    #[tool("Check server health")]
556    async fn status(&self) -> String {{
557        "OK".to_string()
558    }}
559}}
560
561#[event(fetch)]
562async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {{
563    console_error_panic_hook::set_once();
564
565    let server = {struct_name};
566    let mcp = server
567        .into_mcp_server()
568        .with_jwt_auth(env.secret("JWT_SECRET")?.to_string());
569
570    mcp.handle(req).await
571}}
572"#,
573        description = description,
574        struct_name = to_struct_name(&args.name),
575        name = args.name,
576    );
577
578    // Write files
579    write_file(output_dir, "Cargo.toml", &cargo_toml)?;
580    write_file(output_dir, "wrangler.toml", &wrangler_toml)?;
581    fs::create_dir_all(output_dir.join("src"))?;
582    write_file(&output_dir.join("src"), "lib.rs", &lib_rs)?;
583
584    // .gitignore
585    write_file(output_dir, ".gitignore", "/target\n/build\n/node_modules\n")?;
586
587    Ok(())
588}
589
590/// Generate a Cloudflare Workers MCP server with Durable Objects.
591fn generate_cloudflare_workers_do(args: &NewArgs, output_dir: &Path) -> CliResult<()> {
592    let description = args
593        .description
594        .as_deref()
595        .unwrap_or("An MCP server for Cloudflare Workers with Durable Objects");
596
597    // Cargo.toml
598    let cargo_toml = format!(
599        r#"[package]
600name = "{name}"
601version = "0.1.0"
602edition = "2024"
603description = "{description}"
604{author}
605
606[lib]
607crate-type = ["cdylib"]
608
609[dependencies]
610turbomcp-wasm = {{ version = "{sdk_version}", features = ["macros", "streamable"] }}
611worker = "{worker_version}"
612serde = {{ version = "1", features = ["derive"] }}
613serde_json = "1"
614schemars = "1.2"
615wasm-bindgen = "0.2"
616wasm-bindgen-futures = "0.4"
617console_error_panic_hook = "0.1"
618
619[profile.release]
620opt-level = "s"
621lto = true
622"#,
623        name = args.name,
624        description = description,
625        sdk_version = SDK_VERSION,
626        worker_version = WORKER_VERSION,
627        author = args
628            .author
629            .as_ref()
630            .map(|a| format!("authors = [\"{}\"]", a))
631            .unwrap_or_default(),
632    );
633
634    // wrangler.toml
635    let wrangler_toml = format!(
636        r#"name = "{name}"
637main = "build/worker/shim.mjs"
638compatibility_date = "2024-01-01"
639
640[build]
641command = "cargo install -q worker-build && worker-build --release"
642
643# Durable Objects for session and state management
644[[durable_objects.bindings]]
645name = "MCP_SESSIONS"
646class_name = "McpSession"
647
648[[durable_objects.bindings]]
649name = "MCP_STATE"
650class_name = "McpState"
651
652[[durable_objects.bindings]]
653name = "MCP_RATE_LIMIT"
654class_name = "McpRateLimit"
655
656[[migrations]]
657tag = "v1"
658new_classes = ["McpSession", "McpState", "McpRateLimit"]
659"#,
660        name = args.name,
661    );
662
663    // src/lib.rs
664    let lib_rs = format!(
665        r#"//! {description}
666
667use turbomcp_wasm::prelude::*;
668use turbomcp_wasm::wasm_server::{{
669    DurableObjectSessionStore,
670    DurableObjectStateStore,
671    DurableObjectRateLimiter,
672    RateLimitConfig,
673    StreamableHandler,
674}};
675use serde::{{Deserialize, Serialize}};
676use worker::*;
677use std::sync::Arc;
678
679#[derive(Clone)]
680struct {struct_name} {{
681    state_store: DurableObjectStateStore,
682}}
683
684#[derive(Deserialize, schemars::JsonSchema)]
685struct HelloArgs {{
686    /// Name to greet
687    name: String,
688}}
689
690#[derive(Deserialize, schemars::JsonSchema)]
691struct SaveArgs {{
692    /// Key to save under
693    key: String,
694    /// Value to save
695    value: String,
696}}
697
698#[derive(Deserialize, schemars::JsonSchema)]
699struct LoadArgs {{
700    /// Key to load
701    key: String,
702}}
703
704#[server(name = "{name}", version = "0.1.0")]
705impl {struct_name} {{
706    /// Say hello to someone
707    #[tool("Say hello to someone")]
708    async fn hello(&self, args: HelloArgs) -> String {{
709        format!("Hello, {{}}!", args.name)
710    }}
711
712    /// Save a value to persistent state
713    #[tool("Save a value to persistent storage")]
714    async fn save(&self, ctx: Arc<RequestContext>, args: SaveArgs) -> Result<String, ToolError> {{
715        let session_id = ctx.session_id().unwrap_or("default");
716
717        self.state_store
718            .set(session_id, &args.key, &args.value)
719            .await
720            .map_err(|e| ToolError::new(format!("Failed to save: {{}}", e)))?;
721
722        Ok(format!("Saved '{{}}' = '{{}}'", args.key, args.value))
723    }}
724
725    /// Load a value from persistent state
726    #[tool("Load a value from persistent storage")]
727    async fn load(&self, ctx: Arc<RequestContext>, args: LoadArgs) -> Result<String, ToolError> {{
728        let session_id = ctx.session_id().unwrap_or("default");
729
730        let value: Option<String> = self.state_store
731            .get(session_id, &args.key)
732            .await
733            .map_err(|e| ToolError::new(format!("Failed to load: {{}}", e)))?;
734
735        match value {{
736            Some(v) => Ok(v),
737            None => Ok(format!("Key '{{}}' not found", args.key)),
738        }}
739    }}
740}}
741
742#[event(fetch)]
743async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {{
744    console_error_panic_hook::set_once();
745
746    // Initialize Durable Object stores
747    let session_store = DurableObjectSessionStore::from_env(&env, "MCP_SESSIONS")?;
748    let state_store = DurableObjectStateStore::from_env(&env, "MCP_STATE")?;
749    let rate_limiter = DurableObjectRateLimiter::from_env(&env, "MCP_RATE_LIMIT")?
750        .with_config(RateLimitConfig::per_minute(100));
751
752    let server = {struct_name} {{ state_store }};
753    let mcp = server.into_mcp_server();
754
755    // Use Streamable HTTP with session persistence
756    let handler = StreamableHandler::new(mcp)
757        .with_session_store(session_store);
758
759    handler.handle(req).await
760}}
761
762// Durable Object implementations (minimal stubs - expand as needed)
763// See turbomcp_wasm::wasm_server::durable_objects for protocol documentation
764
765#[durable_object]
766pub struct McpSession {{
767    state: State,
768    #[allow(dead_code)]
769    env: Env,
770}}
771
772#[durable_object]
773impl DurableObject for McpSession {{
774    fn new(state: State, env: Env) -> Self {{
775        Self {{ state, env }}
776    }}
777
778    async fn fetch(&mut self, req: Request) -> Result<Response> {{
779        // Handle session storage requests
780        // See DurableObjectSessionStore protocol documentation
781        Response::ok("{{}}")
782    }}
783}}
784
785#[durable_object]
786pub struct McpState {{
787    state: State,
788    #[allow(dead_code)]
789    env: Env,
790}}
791
792#[durable_object]
793impl DurableObject for McpState {{
794    fn new(state: State, env: Env) -> Self {{
795        Self {{ state, env }}
796    }}
797
798    async fn fetch(&mut self, req: Request) -> Result<Response> {{
799        // Handle state storage requests
800        // See DurableObjectStateStore protocol documentation
801        Response::ok("{{}}")
802    }}
803}}
804
805#[durable_object]
806pub struct McpRateLimit {{
807    state: State,
808    #[allow(dead_code)]
809    env: Env,
810}}
811
812#[durable_object]
813impl DurableObject for McpRateLimit {{
814    fn new(state: State, env: Env) -> Self {{
815        Self {{ state, env }}
816    }}
817
818    async fn fetch(&mut self, req: Request) -> Result<Response> {{
819        // Handle rate limiting requests
820        // See DurableObjectRateLimiter protocol documentation
821        Response::ok("{{}}")
822    }}
823}}
824"#,
825        description = description,
826        struct_name = to_struct_name(&args.name),
827        name = args.name,
828    );
829
830    // Write files
831    write_file(output_dir, "Cargo.toml", &cargo_toml)?;
832    write_file(output_dir, "wrangler.toml", &wrangler_toml)?;
833    fs::create_dir_all(output_dir.join("src"))?;
834    write_file(&output_dir.join("src"), "lib.rs", &lib_rs)?;
835
836    // .gitignore
837    write_file(output_dir, ".gitignore", "/target\n/build\n/node_modules\n")?;
838
839    Ok(())
840}
841
842/// Initialize a git repository in the project directory.
843fn init_git(output_dir: &Path) -> CliResult<()> {
844    let status = Command::new("git")
845        .arg("init")
846        .current_dir(output_dir)
847        .stdout(std::process::Stdio::null())
848        .stderr(std::process::Stdio::null())
849        .status();
850
851    match status {
852        Ok(s) if s.success() => {
853            println!("  Initialized git repository");
854            Ok(())
855        }
856        _ => {
857            println!("  Warning: Failed to initialize git repository");
858            Ok(()) // Don't fail the whole operation
859        }
860    }
861}
862
863/// Write a file to the specified directory.
864fn write_file(dir: &Path, name: &str, content: &str) -> CliResult<()> {
865    let path = dir.join(name);
866    fs::write(&path, content)
867        .map_err(|e| CliError::Other(format!("Failed to write {}: {}", path.display(), e)))
868}
869
870/// Convert a project name to a valid Rust struct name.
871fn to_struct_name(name: &str) -> String {
872    let name = name
873        .chars()
874        .map(|c| if c.is_alphanumeric() { c } else { '_' })
875        .collect::<String>();
876
877    // Convert to PascalCase
878    let mut result = String::new();
879    let mut capitalize_next = true;
880
881    for c in name.chars() {
882        if c == '_' {
883            capitalize_next = true;
884        } else if capitalize_next {
885            result.push(c.to_ascii_uppercase());
886            capitalize_next = false;
887        } else {
888            result.push(c);
889        }
890    }
891
892    // Ensure it starts with a letter
893    if result.chars().next().is_none_or(|c| c.is_ascii_digit()) {
894        result = format!("Server{}", result);
895    }
896
897    result
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    #[test]
905    fn test_to_struct_name() {
906        assert_eq!(to_struct_name("my-server"), "MyServer");
907        assert_eq!(to_struct_name("hello_world"), "HelloWorld");
908        assert_eq!(to_struct_name("123test"), "Server123test");
909        assert_eq!(to_struct_name("simple"), "Simple");
910    }
911
912    #[test]
913    fn test_template_display() {
914        assert_eq!(ProjectTemplate::Minimal.to_string(), "minimal");
915        assert_eq!(
916            ProjectTemplate::CloudflareWorkers.to_string(),
917            "cloudflare-workers"
918        );
919    }
920
921    fn test_args(template: ProjectTemplate, output: &Path) -> NewArgs {
922        NewArgs {
923            name: "generated-server".to_string(),
924            template,
925            output: Some(output.to_path_buf()),
926            git: false,
927            description: None,
928            author: None,
929        }
930    }
931
932    #[test]
933    fn generated_native_templates_use_current_sdk_version() {
934        let dir = tempfile::tempdir().unwrap();
935        let minimal = dir.path().join("minimal");
936        fs::create_dir_all(&minimal).unwrap();
937        generate_minimal(&test_args(ProjectTemplate::Minimal, &minimal), &minimal).unwrap();
938        let cargo_toml = fs::read_to_string(minimal.join("Cargo.toml")).unwrap();
939        assert!(cargo_toml.contains(&format!("turbomcp = \"{}\"", SDK_VERSION)));
940
941        let full = dir.path().join("full");
942        fs::create_dir_all(&full).unwrap();
943        generate_full(&test_args(ProjectTemplate::Full, &full), &full).unwrap();
944        let cargo_toml = fs::read_to_string(full.join("Cargo.toml")).unwrap();
945        let main_rs = fs::read_to_string(full.join("src/main.rs")).unwrap();
946        assert!(cargo_toml.contains(&format!("version = \"{}\"", SDK_VERSION)));
947        assert!(!main_rs.contains("ResourceError"));
948    }
949
950    #[test]
951    fn generated_worker_templates_use_current_versions_and_panic_hook() {
952        let dir = tempfile::tempdir().unwrap();
953        let cases = [
954            (
955                ProjectTemplate::CloudflareWorkers,
956                generate_cloudflare_workers as fn(&NewArgs, &Path) -> CliResult<()>,
957            ),
958            (
959                ProjectTemplate::CloudflareWorkersOauth,
960                generate_cloudflare_workers_oauth as fn(&NewArgs, &Path) -> CliResult<()>,
961            ),
962            (
963                ProjectTemplate::CloudflareWorkersDurableObjects,
964                generate_cloudflare_workers_do as fn(&NewArgs, &Path) -> CliResult<()>,
965            ),
966        ];
967
968        for (template, generate) in cases {
969            let output = dir.path().join(template.to_string());
970            fs::create_dir_all(&output).unwrap();
971            generate(&test_args(template, &output), &output).unwrap();
972            let cargo_toml = fs::read_to_string(output.join("Cargo.toml")).unwrap();
973            assert!(cargo_toml.contains(&format!("version = \"{}\"", SDK_VERSION)));
974            assert!(cargo_toml.contains(&format!("worker = \"{}\"", WORKER_VERSION)));
975            assert!(cargo_toml.contains("console_error_panic_hook = \"0.1\""));
976            assert!(!cargo_toml.contains("3.0"));
977        }
978    }
979}