1use 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
15pub fn execute(args: &NewArgs) -> CliResult<()> {
17 let output_dir = args
19 .output
20 .clone()
21 .unwrap_or_else(|| PathBuf::from(&args.name));
22
23 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 fs::create_dir_all(&output_dir)
36 .map_err(|e| CliError::Other(format!("Failed to create directory: {}", e)))?;
37
38 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 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
76fn 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 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 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_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 write_file(output_dir, ".gitignore", "/target\n")?;
152
153 Ok(())
154}
155
156fn 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 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 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_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 write_file(output_dir, ".gitignore", "/target\n")?;
327
328 Ok(())
329}
330
331fn 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 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 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 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_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 write_file(output_dir, ".gitignore", "/target\n/build\n/node_modules\n")?;
452
453 Ok(())
454}
455
456fn 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 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 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 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_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 write_file(output_dir, ".gitignore", "/target\n/build\n/node_modules\n")?;
586
587 Ok(())
588}
589
590fn 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 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 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 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_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 write_file(output_dir, ".gitignore", "/target\n/build\n/node_modules\n")?;
838
839 Ok(())
840}
841
842fn 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(()) }
860 }
861}
862
863fn 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
870fn 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 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 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}