1use crate::{
4 app,
5 codegen::{CodegenOutcome, CodegenRequest, CodegenTargetKind, DtoConfig, SchemaKind, TargetLanguage},
6 init::InitRequest,
7 mcp::{
8 errors::map_app_error_to_mcp,
9 params::{
10 EmptyParams, GenerateAsyncapiBundleParams, GenerateAsyncapiFixturesParams, GenerateAsyncapiHandlersParams,
11 GenerateAsyncapiTestAppParams, GenerateGraphqlParams, GenerateJsonrpcParams, GenerateOpenapiParams,
12 GeneratePhpDtoParams, GenerateProtobufParams, InitProjectParams, ValidateAsyncapiParams,
13 },
14 },
15};
16use anyhow::{Result, bail};
17use rmcp::{
18 ServerHandler, ServiceExt,
19 handler::server::{router::tool::ToolRouter, wrapper::Parameters},
20 model::*,
21 tool, tool_handler, tool_router,
22 transport::stdio,
23};
24use std::path::PathBuf;
25
26#[cfg(feature = "mcp-http")]
27use rmcp::transport::streamable_http_server::{StreamableHttpService, session::local::LocalSessionManager};
28
29#[derive(Clone)]
31pub struct SpikardMcp {
32 tool_router: ToolRouter<SpikardMcp>,
33}
34
35impl SpikardMcp {
36 #[must_use]
38 pub fn new() -> Self {
39 Self {
40 tool_router: Self::tool_router(),
41 }
42 }
43
44 fn init_project_impl(&self, params: InitProjectParams) -> Result<crate::init::InitResponse, rmcp::ErrorData> {
45 let language = parse_target_language_or_default(params.language.as_deref(), TargetLanguage::Python)?;
46 let base_dir = params.directory.unwrap_or_else(|| ".".to_string());
47 let request = InitRequest {
48 project_name: params.name.clone(),
49 language,
50 project_dir: PathBuf::from(base_dir).join(¶ms.name),
51 schema_path: params.schema_path.map(PathBuf::from),
52 };
53
54 app::init_project(request).map_err(map_app_error_to_mcp)
55 }
56
57 fn generate_openapi_impl(&self, params: GenerateOpenapiParams) -> Result<CodegenOutcome, rmcp::ErrorData> {
58 let language = parse_target_language_or_default(params.language.as_deref(), TargetLanguage::Python)?;
59 let mut dto = DtoConfig::default();
60 if let Some(dto_name) = params.dto.as_deref() {
61 apply_dto_choice(&mut dto, language, dto_name)?;
62 }
63
64 app::execute_codegen(CodegenRequest {
65 schema_path: PathBuf::from(params.schema),
66 schema_kind: SchemaKind::OpenApi,
67 target: CodegenTargetKind::Server {
68 language,
69 output: params.output.map(PathBuf::from),
70 },
71 dto: Some(dto),
72 })
73 .map_err(map_app_error_to_mcp)
74 }
75
76 fn generate_asyncapi_handlers_impl(
77 &self,
78 params: GenerateAsyncapiHandlersParams,
79 ) -> Result<CodegenOutcome, rmcp::ErrorData> {
80 let language = parse_target_language(¶ms.language)?;
81 let mut dto = DtoConfig::default();
82 if let Some(dto_name) = params.dto.as_deref() {
83 apply_dto_choice(&mut dto, language, dto_name)?;
84 }
85
86 app::execute_codegen(CodegenRequest {
87 schema_path: PathBuf::from(params.schema),
88 schema_kind: SchemaKind::AsyncApi,
89 target: CodegenTargetKind::AsyncHandlers {
90 language,
91 output: PathBuf::from(params.output),
92 },
93 dto: Some(dto),
94 })
95 .map_err(map_app_error_to_mcp)
96 }
97
98 fn generate_jsonrpc_impl(&self, params: GenerateJsonrpcParams) -> Result<CodegenOutcome, rmcp::ErrorData> {
99 let language = parse_target_language_or_default(params.language.as_deref(), TargetLanguage::Python)?;
100
101 app::execute_codegen(CodegenRequest {
102 schema_path: PathBuf::from(params.schema),
103 schema_kind: SchemaKind::OpenRpc,
104 target: CodegenTargetKind::JsonRpcHandlers {
105 language,
106 output: params
107 .output
108 .map(PathBuf::from)
109 .unwrap_or_else(|| default_jsonrpc_output(language)),
110 },
111 dto: None,
112 })
113 .map_err(map_app_error_to_mcp)
114 }
115
116 fn generate_graphql_impl(&self, params: GenerateGraphqlParams) -> Result<CodegenOutcome, rmcp::ErrorData> {
117 let language = parse_target_language_or_default(params.language.as_deref(), TargetLanguage::Python)?;
118 let output = params
119 .output
120 .map(PathBuf::from)
121 .unwrap_or_else(|| default_graphql_output(language));
122
123 app::execute_codegen(CodegenRequest {
124 schema_path: PathBuf::from(params.schema),
125 schema_kind: SchemaKind::GraphQL,
126 target: CodegenTargetKind::GraphQL {
127 language,
128 output,
129 target: params.target.unwrap_or_else(|| "all".to_string()),
130 },
131 dto: None,
132 })
133 .map_err(map_app_error_to_mcp)
134 }
135
136 fn generate_protobuf_impl(&self, params: GenerateProtobufParams) -> Result<CodegenOutcome, rmcp::ErrorData> {
137 let language = parse_target_language_or_default(params.language.as_deref(), TargetLanguage::Python)?;
138
139 app::execute_codegen(CodegenRequest {
140 schema_path: PathBuf::from(params.schema),
141 schema_kind: SchemaKind::Protobuf,
142 target: CodegenTargetKind::Protobuf {
143 language,
144 output: PathBuf::from(params.output),
145 target: params.target.unwrap_or_else(|| "all".to_string()),
146 include_paths: params
147 .include
148 .unwrap_or_default()
149 .into_iter()
150 .map(PathBuf::from)
151 .collect(),
152 },
153 dto: None,
154 })
155 .map_err(map_app_error_to_mcp)
156 }
157
158 fn generate_php_dto_impl(
159 &self,
160 params: GeneratePhpDtoParams,
161 ) -> Result<Vec<crate::codegen::GeneratedAsset>, rmcp::ErrorData> {
162 let output = params.output.unwrap_or_else(|| "src/Generated".to_string());
163 app::generate_php_dto(PathBuf::from(output).as_path()).map_err(map_app_error_to_mcp)
164 }
165
166 fn generate_asyncapi_fixtures_impl(
167 &self,
168 params: GenerateAsyncapiFixturesParams,
169 ) -> Result<CodegenOutcome, rmcp::ErrorData> {
170 app::execute_codegen_unvalidated(CodegenRequest {
171 schema_path: PathBuf::from(params.schema),
172 schema_kind: SchemaKind::AsyncApi,
173 target: CodegenTargetKind::AsyncFixtures {
174 output: PathBuf::from(params.output.unwrap_or_else(|| "testing_data".to_string())),
175 },
176 dto: None,
177 })
178 .map_err(map_app_error_to_mcp)
179 }
180
181 fn generate_asyncapi_test_app_impl(
182 &self,
183 params: GenerateAsyncapiTestAppParams,
184 ) -> Result<CodegenOutcome, rmcp::ErrorData> {
185 let language = parse_target_language(¶ms.language)?;
186 app::execute_codegen_unvalidated(CodegenRequest {
187 schema_path: PathBuf::from(params.schema),
188 schema_kind: SchemaKind::AsyncApi,
189 target: CodegenTargetKind::AsyncTestApp {
190 language,
191 output: PathBuf::from(params.output),
192 },
193 dto: None,
194 })
195 .map_err(map_app_error_to_mcp)
196 }
197
198 fn generate_asyncapi_bundle_impl(
199 &self,
200 params: GenerateAsyncapiBundleParams,
201 ) -> Result<CodegenOutcome, rmcp::ErrorData> {
202 app::execute_codegen_unvalidated(CodegenRequest {
203 schema_path: PathBuf::from(params.schema),
204 schema_kind: SchemaKind::AsyncApi,
205 target: CodegenTargetKind::AsyncAll {
206 output: PathBuf::from(params.output.unwrap_or_else(|| ".".to_string())),
207 },
208 dto: None,
209 })
210 .map_err(map_app_error_to_mcp)
211 }
212
213 fn validate_asyncapi_impl(
214 &self,
215 params: ValidateAsyncapiParams,
216 ) -> Result<app::AsyncApiValidationSummary, rmcp::ErrorData> {
217 app::validate_asyncapi_schema(PathBuf::from(params.schema).as_path()).map_err(map_app_error_to_mcp)
218 }
219}
220
221#[tool_router]
222impl SpikardMcp {
223 #[tool(
225 description = "Initialize a new Spikard project in the requested language and return the created files and next steps.",
226 annotations(title = "Init Project")
227 )]
228 fn init_project(
229 &self,
230 Parameters(params): Parameters<InitProjectParams>,
231 ) -> Result<CallToolResult, rmcp::ErrorData> {
232 json_tool_response(&self.init_project_impl(params)?)
233 }
234
235 #[tool(
237 description = "Generate Spikard server handlers from an OpenAPI schema.",
238 annotations(title = "Generate OpenAPI", read_only_hint = false, idempotent_hint = true)
239 )]
240 fn generate_openapi(
241 &self,
242 Parameters(params): Parameters<GenerateOpenapiParams>,
243 ) -> Result<CallToolResult, rmcp::ErrorData> {
244 json_tool_response(&self.generate_openapi_impl(params)?)
245 }
246
247 #[tool(
249 description = "Generate AsyncAPI handler scaffolding for a target language.",
250 annotations(title = "Generate AsyncAPI Handlers", read_only_hint = false, idempotent_hint = true)
251 )]
252 fn generate_asyncapi_handlers(
253 &self,
254 Parameters(params): Parameters<GenerateAsyncapiHandlersParams>,
255 ) -> Result<CallToolResult, rmcp::ErrorData> {
256 json_tool_response(&self.generate_asyncapi_handlers_impl(params)?)
257 }
258
259 #[tool(
261 description = "Generate JSON-RPC handlers from an OpenRPC schema.",
262 annotations(title = "Generate JSON-RPC", read_only_hint = false, idempotent_hint = true)
263 )]
264 fn generate_jsonrpc(
265 &self,
266 Parameters(params): Parameters<GenerateJsonrpcParams>,
267 ) -> Result<CallToolResult, rmcp::ErrorData> {
268 json_tool_response(&self.generate_jsonrpc_impl(params)?)
269 }
270
271 #[tool(
273 description = "Generate GraphQL types, resolvers, or schema definitions for a target language.",
274 annotations(title = "Generate GraphQL", read_only_hint = false, idempotent_hint = true)
275 )]
276 fn generate_graphql(
277 &self,
278 Parameters(params): Parameters<GenerateGraphqlParams>,
279 ) -> Result<CallToolResult, rmcp::ErrorData> {
280 json_tool_response(&self.generate_graphql_impl(params)?)
281 }
282
283 #[tool(
285 description = "Generate Protobuf messages and gRPC services for a target language.",
286 annotations(title = "Generate Protobuf", read_only_hint = false, idempotent_hint = true)
287 )]
288 fn generate_protobuf(
289 &self,
290 Parameters(params): Parameters<GenerateProtobufParams>,
291 ) -> Result<CallToolResult, rmcp::ErrorData> {
292 json_tool_response(&self.generate_protobuf_impl(params)?)
293 }
294
295 #[tool(
297 description = "Generate the PHP DTO classes used for Spikard integrations.",
298 annotations(title = "Generate PHP DTO", read_only_hint = false, idempotent_hint = true)
299 )]
300 fn generate_php_dto(
301 &self,
302 Parameters(params): Parameters<GeneratePhpDtoParams>,
303 ) -> Result<CallToolResult, rmcp::ErrorData> {
304 json_tool_response(&self.generate_php_dto_impl(params)?)
305 }
306
307 #[tool(
309 description = "Generate AsyncAPI test fixtures used by Spikard's codegen-first testing flows.",
310 annotations(title = "Generate AsyncAPI Fixtures", read_only_hint = false, idempotent_hint = true)
311 )]
312 fn generate_asyncapi_fixtures(
313 &self,
314 Parameters(params): Parameters<GenerateAsyncapiFixturesParams>,
315 ) -> Result<CallToolResult, rmcp::ErrorData> {
316 json_tool_response(&self.generate_asyncapi_fixtures_impl(params)?)
317 }
318
319 #[tool(
321 description = "Generate a language-specific AsyncAPI test application.",
322 annotations(title = "Generate AsyncAPI Test App", read_only_hint = false, idempotent_hint = true)
323 )]
324 fn generate_asyncapi_test_app(
325 &self,
326 Parameters(params): Parameters<GenerateAsyncapiTestAppParams>,
327 ) -> Result<CallToolResult, rmcp::ErrorData> {
328 json_tool_response(&self.generate_asyncapi_test_app_impl(params)?)
329 }
330
331 #[tool(
333 description = "Generate AsyncAPI fixtures and test apps for all supported languages.",
334 annotations(title = "Generate AsyncAPI Bundle", read_only_hint = false, idempotent_hint = true)
335 )]
336 fn generate_asyncapi_bundle(
337 &self,
338 Parameters(params): Parameters<GenerateAsyncapiBundleParams>,
339 ) -> Result<CallToolResult, rmcp::ErrorData> {
340 json_tool_response(&self.generate_asyncapi_bundle_impl(params)?)
341 }
342
343 #[tool(
345 description = "Validate an AsyncAPI schema and return its protocol and channel summary.",
346 annotations(title = "Validate AsyncAPI", read_only_hint = true, idempotent_hint = true)
347 )]
348 fn validate_asyncapi(
349 &self,
350 Parameters(params): Parameters<ValidateAsyncapiParams>,
351 ) -> Result<CallToolResult, rmcp::ErrorData> {
352 json_tool_response(&self.validate_asyncapi_impl(params)?)
353 }
354
355 #[tool(
357 description = "Return the current Spikard feature summary and binding installation hints.",
358 annotations(title = "Get Features", read_only_hint = true, idempotent_hint = true)
359 )]
360 fn get_features(&self, Parameters(_): Parameters<EmptyParams>) -> Result<CallToolResult, rmcp::ErrorData> {
361 json_tool_response(&app::feature_summary())
362 }
363}
364
365#[tool_handler]
366impl ServerHandler for SpikardMcp {
367 fn get_info(&self) -> ServerInfo {
368 let mut capabilities = ServerCapabilities::default();
369 capabilities.tools = Some(ToolsCapability::default());
370
371 let server_info = Implementation::new("spikard-mcp", env!("CARGO_PKG_VERSION"))
372 .with_title("Spikard MCP Server")
373 .with_description(
374 "Codegen-first MCP server for project scaffolding, schema validation, and test-app generation.",
375 )
376 .with_website_url("https://spikard.dev/");
377
378 InitializeResult::new(capabilities)
379 .with_server_info(server_info)
380 .with_instructions(
381 "Use these tools to scaffold new Spikard projects, generate code from API schemas, validate AsyncAPI documents, and create fixture-driven test assets.",
382 )
383 }
384}
385
386impl Default for SpikardMcp {
387 fn default() -> Self {
388 Self::new()
389 }
390}
391
392pub async fn start_mcp_server() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
394 let service = SpikardMcp::new().serve(stdio()).await?;
395 service.waiting().await?;
396 Ok(())
397}
398
399#[cfg(feature = "mcp-http")]
401pub async fn start_mcp_server_http(
402 host: impl AsRef<str>,
403 port: u16,
404) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
405 use axum::Router;
406 use std::net::SocketAddr;
407
408 let http_service = StreamableHttpService::new(
409 || Ok(SpikardMcp::new()),
410 LocalSessionManager::default().into(),
411 Default::default(),
412 );
413 let router = Router::new().nest_service("/mcp", http_service);
414
415 let addr: SocketAddr = format!("{}:{}", host.as_ref(), port)
416 .parse()
417 .map_err(|e| format!("Invalid address: {}", e))?;
418 let listener = tokio::net::TcpListener::bind(addr).await?;
419 axum::serve(listener, router).await?;
420 Ok(())
421}
422
423fn json_tool_response<T: serde::Serialize>(value: &T) -> Result<CallToolResult, rmcp::ErrorData> {
424 let json = serde_json::to_string_pretty(value)
425 .map_err(|error| rmcp::ErrorData::internal_error(format!("Failed to serialize result: {}", error), None))?;
426 Ok(CallToolResult::success(vec![Content::text(json)]))
427}
428
429fn parse_target_language(language: &str) -> Result<TargetLanguage, rmcp::ErrorData> {
430 match language.to_ascii_lowercase().as_str() {
431 "python" => Ok(TargetLanguage::Python),
432 "typescript" => Ok(TargetLanguage::TypeScript),
433 "rust" => Ok(TargetLanguage::Rust),
434 "ruby" => Ok(TargetLanguage::Ruby),
435 "php" => Ok(TargetLanguage::Php),
436 "elixir" => Ok(TargetLanguage::Elixir),
437 other => Err(rmcp::ErrorData::invalid_params(
438 format!(
439 "Unsupported language '{}'. Use python, typescript, rust, ruby, php, or elixir.",
440 other
441 ),
442 None,
443 )),
444 }
445}
446
447fn parse_target_language_or_default(
448 language: Option<&str>,
449 default: TargetLanguage,
450) -> Result<TargetLanguage, rmcp::ErrorData> {
451 match language {
452 Some(language) => parse_target_language(language),
453 None => Ok(default),
454 }
455}
456
457fn apply_dto_choice(config: &mut DtoConfig, language: TargetLanguage, dto: &str) -> Result<(), rmcp::ErrorData> {
458 match (language, dto.to_ascii_lowercase().as_str()) {
459 (TargetLanguage::Python, "dataclass") => {
460 config.python = crate::codegen::PythonDtoStyle::Dataclass;
461 Ok(())
462 }
463 (TargetLanguage::Python, "msgspec") => {
464 config.python = crate::codegen::PythonDtoStyle::Msgspec;
465 Ok(())
466 }
467 (TargetLanguage::TypeScript, "zod") => {
468 config.node = crate::codegen::NodeDtoStyle::Zod;
469 Ok(())
470 }
471 (TargetLanguage::Ruby, "dryschema") | (TargetLanguage::Ruby, "dry_schema") => {
472 config.ruby = crate::codegen::RubyDtoStyle::DrySchema;
473 Ok(())
474 }
475 (TargetLanguage::Rust, "serde") => {
476 config.rust = crate::codegen::RustDtoStyle::SerdeStruct;
477 Ok(())
478 }
479 (TargetLanguage::Php, "readonlyclass") | (TargetLanguage::Php, "readonly_class") => {
480 config.php = crate::codegen::PhpDtoStyle::ReadonlyClass;
481 Ok(())
482 }
483 _ => Err(rmcp::ErrorData::invalid_params(
484 format!("DTO '{}' is not supported for {:?}", dto, language),
485 None,
486 )),
487 }
488}
489
490fn default_graphql_output(language: TargetLanguage) -> PathBuf {
491 let ext = match language {
492 TargetLanguage::Python => "py",
493 TargetLanguage::TypeScript => "ts",
494 TargetLanguage::Rust => "rs",
495 TargetLanguage::Ruby => "rb",
496 TargetLanguage::Php => "php",
497 TargetLanguage::Elixir => "ex",
498 };
499 PathBuf::from(format!("generated.{ext}"))
500}
501
502fn default_jsonrpc_output(language: TargetLanguage) -> PathBuf {
503 let ext = match language {
504 TargetLanguage::Python => "py",
505 TargetLanguage::TypeScript => "ts",
506 TargetLanguage::Rust => "rs",
507 TargetLanguage::Ruby => "rb",
508 TargetLanguage::Php => "php",
509 TargetLanguage::Elixir => "ex",
510 };
511 PathBuf::from(format!("handlers.{ext}"))
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use crate::codegen::{SchemaKind, TargetLanguage};
518 use std::collections::BTreeMap;
519 use tempfile::TempDir;
520
521 fn repo_root() -> PathBuf {
522 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
523 .parent()
524 .and_then(|p| p.parent())
525 .expect("CARGO_MANIFEST_DIR should be crates/spikard-cli")
526 .to_path_buf()
527 }
528
529 fn write_temp_graphql_schema(tmp: &TempDir) -> Result<PathBuf> {
530 let schema = tmp.path().join("schema.graphql");
531 std::fs::write(
532 &schema,
533 "type Query {\n hello: String!\n}\n\ntype User {\n id: ID!\n name: String!\n}\n",
534 )?;
535 Ok(schema)
536 }
537
538 fn read_file_map(root: &std::path::Path) -> Result<BTreeMap<PathBuf, String>> {
539 fn walk_dir(
540 root: &std::path::Path,
541 dir: &std::path::Path,
542 files: &mut BTreeMap<PathBuf, String>,
543 ) -> Result<()> {
544 for entry in std::fs::read_dir(dir)? {
545 let entry = entry?;
546 let path = entry.path();
547 if path.is_dir() {
548 walk_dir(root, &path, files)?;
549 continue;
550 }
551
552 files.insert(path.strip_prefix(root)?.to_path_buf(), std::fs::read_to_string(&path)?);
553 }
554 Ok(())
555 }
556
557 let mut files = BTreeMap::new();
558 walk_dir(root, root, &mut files)?;
559 Ok(files)
560 }
561
562 fn collect_prefixed_lines(text: &str, prefix: &str) -> Vec<String> {
563 let mut lines = text
564 .lines()
565 .filter(|line| line.starts_with(prefix))
566 .map(ToOwned::to_owned)
567 .collect::<Vec<_>>();
568 lines.sort();
569 lines
570 }
571
572 #[test]
573 fn test_tool_router_has_expected_routes() {
574 let router = SpikardMcp::tool_router();
575 let expected = [
576 "init_project",
577 "generate_openapi",
578 "generate_asyncapi_handlers",
579 "generate_jsonrpc",
580 "generate_graphql",
581 "generate_protobuf",
582 "generate_php_dto",
583 "generate_asyncapi_fixtures",
584 "generate_asyncapi_test_app",
585 "generate_asyncapi_bundle",
586 "validate_asyncapi",
587 "get_features",
588 ];
589
590 for route in expected {
591 assert!(router.has_route(route), "missing route {route}");
592 }
593 assert_eq!(router.list_all().len(), expected.len());
594 }
595
596 #[test]
597 fn test_server_info() {
598 let server = SpikardMcp::new();
599 let info = server.get_info();
600
601 assert_eq!(info.server_info.name, "spikard-mcp");
602 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
603 assert!(info.capabilities.tools.is_some());
604 }
605
606 #[test]
607 fn test_generate_openapi_impl_matches_service() -> Result<()> {
608 let server = SpikardMcp::new();
609 let schema = repo_root().join("testing_data/schemas/todo-api.openapi.yaml");
610
611 let tool_result = server.generate_openapi_impl(GenerateOpenapiParams {
612 schema: schema.display().to_string(),
613 language: Some("python".to_string()),
614 output: None,
615 dto: Some("dataclass".to_string()),
616 })?;
617
618 let mut dto = DtoConfig::default();
619 dto.python = crate::codegen::PythonDtoStyle::Dataclass;
620 let app_result = app::execute_codegen(CodegenRequest {
621 schema_path: schema,
622 schema_kind: SchemaKind::OpenApi,
623 target: CodegenTargetKind::Server {
624 language: TargetLanguage::Python,
625 output: None,
626 },
627 dto: Some(dto),
628 })?;
629
630 match (tool_result, app_result) {
631 (CodegenOutcome::InMemory(tool_code), CodegenOutcome::InMemory(app_code)) => {
632 assert_eq!(tool_code, app_code);
633 }
634 _ => panic!("expected in-memory code generation results"),
635 }
636
637 Ok(())
638 }
639
640 #[test]
641 fn test_generate_jsonrpc_impl_matches_service() -> Result<()> {
642 let server = SpikardMcp::new();
643 let schema = repo_root().join("testing_data/schemas/user-api.openrpc.json");
644 let tmp = TempDir::new()?;
645 let tool_output = tmp.path().join("tool_handlers.py");
646 let app_output = tmp.path().join("app_handlers.py");
647
648 let tool_result = server.generate_jsonrpc_impl(GenerateJsonrpcParams {
649 schema: schema.display().to_string(),
650 language: Some("python".to_string()),
651 output: Some(tool_output.display().to_string()),
652 })?;
653
654 let app_result = app::execute_codegen(CodegenRequest {
655 schema_path: schema,
656 schema_kind: SchemaKind::OpenRpc,
657 target: CodegenTargetKind::JsonRpcHandlers {
658 language: TargetLanguage::Python,
659 output: app_output.clone(),
660 },
661 dto: None,
662 })?;
663
664 match (tool_result, app_result) {
665 (CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
666 assert_eq!(tool_files.len(), 1);
667 assert_eq!(app_files.len(), 1);
668 assert_eq!(
669 std::fs::read_to_string(&tool_output)?,
670 std::fs::read_to_string(&app_output)?
671 );
672 }
673 _ => panic!("expected file-based JSON-RPC generation results"),
674 }
675
676 Ok(())
677 }
678
679 #[test]
680 fn test_generate_graphql_impl_matches_service() -> Result<()> {
681 let server = SpikardMcp::new();
682 let tmp = TempDir::new()?;
683 let schema = write_temp_graphql_schema(&tmp)?;
684 let tool_output = tmp.path().join("tool_generated.py");
685 let app_output = tmp.path().join("app_generated.py");
686
687 let tool_result = server.generate_graphql_impl(GenerateGraphqlParams {
688 schema: schema.display().to_string(),
689 language: Some("python".to_string()),
690 output: Some(tool_output.display().to_string()),
691 target: Some("all".to_string()),
692 })?;
693
694 let app_result = app::execute_codegen(CodegenRequest {
695 schema_path: schema,
696 schema_kind: SchemaKind::GraphQL,
697 target: CodegenTargetKind::GraphQL {
698 language: TargetLanguage::Python,
699 output: app_output.clone(),
700 target: "all".to_string(),
701 },
702 dto: None,
703 })?;
704
705 match (tool_result, app_result) {
706 (CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
707 assert_eq!(tool_files.len(), 1);
708 assert_eq!(app_files.len(), 1);
709 assert_eq!(
710 std::fs::read_to_string(&tool_output)?,
711 std::fs::read_to_string(&app_output)?
712 );
713 }
714 _ => panic!("expected file-based GraphQL generation results"),
715 }
716
717 Ok(())
718 }
719
720 #[test]
721 fn test_generate_protobuf_impl_matches_service() -> Result<()> {
722 let server = SpikardMcp::new();
723 let schema = repo_root().join("testing_data/schemas/user-service.proto");
724 let tmp = TempDir::new()?;
725 let tool_output = tmp.path().join("tool_generated.ts");
726 let app_output = tmp.path().join("app_generated.ts");
727
728 let tool_result = server.generate_protobuf_impl(GenerateProtobufParams {
729 schema: schema.display().to_string(),
730 language: Some("typescript".to_string()),
731 output: tool_output.display().to_string(),
732 target: Some("all".to_string()),
733 include: None,
734 })?;
735
736 let app_result = app::execute_codegen(CodegenRequest {
737 schema_path: schema,
738 schema_kind: SchemaKind::Protobuf,
739 target: CodegenTargetKind::Protobuf {
740 language: TargetLanguage::TypeScript,
741 output: app_output.clone(),
742 target: "all".to_string(),
743 include_paths: Vec::new(),
744 },
745 dto: None,
746 })?;
747
748 match (tool_result, app_result) {
749 (CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
750 assert_eq!(tool_files.len(), 1);
751 assert_eq!(app_files.len(), 1);
752 let tool_code = std::fs::read_to_string(&tool_output)?;
753 let app_code = std::fs::read_to_string(&app_output)?;
754 for expected in [
755 "export interface User",
756 "export interface GetUserRequest",
757 "export interface UserResponse",
758 "export class UserServiceService",
759 ] {
760 assert!(tool_code.contains(expected), "tool output missing {expected}");
761 assert!(app_code.contains(expected), "app output missing {expected}");
762 }
763 assert_eq!(
764 tool_code.matches("export interface").count(),
765 app_code.matches("export interface").count()
766 );
767 assert_eq!(
768 tool_code.matches("export enum").count(),
769 app_code.matches("export enum").count()
770 );
771 }
772 _ => panic!("expected file-based Protobuf generation results"),
773 }
774
775 Ok(())
776 }
777
778 #[test]
779 fn test_generate_asyncapi_bundle_impl_matches_service_asset_count() -> Result<()> {
780 let server = SpikardMcp::new();
781 let schema = repo_root().join("testing_data/schemas/chat-service.asyncapi.yaml");
782 let tool_tmp = TempDir::new()?;
783 let app_tmp = TempDir::new()?;
784
785 let tool_result = server.generate_asyncapi_bundle_impl(GenerateAsyncapiBundleParams {
786 schema: schema.display().to_string(),
787 output: Some(tool_tmp.path().display().to_string()),
788 })?;
789
790 let app_result = app::execute_codegen_unvalidated(CodegenRequest {
791 schema_path: schema,
792 schema_kind: SchemaKind::AsyncApi,
793 target: CodegenTargetKind::AsyncAll {
794 output: app_tmp.path().to_path_buf(),
795 },
796 dto: None,
797 })?;
798
799 match (tool_result, app_result) {
800 (CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
801 assert_eq!(tool_files.len(), app_files.len());
802 assert!(tool_files.len() >= 30, "expected fixtures plus six test apps");
803 }
804 _ => panic!("expected file-based AsyncAPI bundle results"),
805 }
806
807 Ok(())
808 }
809
810 #[test]
811 fn test_generate_asyncapi_handlers_impl_matches_service() -> Result<()> {
812 let server = SpikardMcp::new();
813 let schema = repo_root().join("testing_data/schemas/chat-service.asyncapi.yaml");
814 let tmp = TempDir::new()?;
815 let tool_output = tmp.path().join("tool_handlers.py");
816 let app_output = tmp.path().join("app_handlers.py");
817
818 let tool_result = server.generate_asyncapi_handlers_impl(GenerateAsyncapiHandlersParams {
819 schema: schema.display().to_string(),
820 language: "python".to_string(),
821 output: tool_output.display().to_string(),
822 dto: None,
823 })?;
824
825 let app_result = app::execute_codegen(CodegenRequest {
826 schema_path: schema,
827 schema_kind: SchemaKind::AsyncApi,
828 target: CodegenTargetKind::AsyncHandlers {
829 language: TargetLanguage::Python,
830 output: app_output.clone(),
831 },
832 dto: Some(DtoConfig::default()),
833 })?;
834
835 match (tool_result, app_result) {
836 (CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
837 assert_eq!(tool_files.len(), 1);
838 assert_eq!(app_files.len(), 1);
839 let tool_code = std::fs::read_to_string(&tool_output)?;
840 let app_code = std::fs::read_to_string(&app_output)?;
841 assert_eq!(
842 collect_prefixed_lines(&tool_code, "class "),
843 collect_prefixed_lines(&app_code, "class ")
844 );
845 assert!(tool_code.contains("@websocket(\"/chat/{roomId}\")"));
846 assert!(app_code.contains("@websocket(\"/chat/{roomId}\")"));
847 assert!(tool_code.contains("parsed: ChatMessage = msgspec.convert(message, type=ChatMessage)"));
848 assert!(app_code.contains("parsed: ChatMessage = msgspec.convert(message, type=ChatMessage)"));
849 }
850 _ => panic!("expected file-based AsyncAPI handler generation results"),
851 }
852
853 Ok(())
854 }
855
856 #[test]
857 fn test_generate_asyncapi_fixtures_impl_matches_service() -> Result<()> {
858 let server = SpikardMcp::new();
859 let schema = repo_root().join("testing_data/schemas/chat-service.asyncapi.yaml");
860 let tool_tmp = TempDir::new()?;
861 let app_tmp = TempDir::new()?;
862
863 let tool_result = server.generate_asyncapi_fixtures_impl(GenerateAsyncapiFixturesParams {
864 schema: schema.display().to_string(),
865 output: Some(tool_tmp.path().display().to_string()),
866 })?;
867
868 let app_result = app::execute_codegen_unvalidated(CodegenRequest {
869 schema_path: schema,
870 schema_kind: SchemaKind::AsyncApi,
871 target: CodegenTargetKind::AsyncFixtures {
872 output: app_tmp.path().to_path_buf(),
873 },
874 dto: None,
875 })?;
876
877 match (tool_result, app_result) {
878 (CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
879 assert_eq!(tool_files.len(), app_files.len());
880 assert_eq!(read_file_map(tool_tmp.path())?, read_file_map(app_tmp.path())?);
881 }
882 _ => panic!("expected file-based AsyncAPI fixture generation results"),
883 }
884
885 Ok(())
886 }
887
888 #[test]
889 fn test_generate_asyncapi_test_app_impl_matches_service() -> Result<()> {
890 let server = SpikardMcp::new();
891 let schema = repo_root().join("testing_data/schemas/chat-service.asyncapi.yaml");
892 let tmp = TempDir::new()?;
893 let tool_output = tmp.path().join("tool_app.ex");
894 let app_output = tmp.path().join("app_app.ex");
895
896 let tool_result = server.generate_asyncapi_test_app_impl(GenerateAsyncapiTestAppParams {
897 schema: schema.display().to_string(),
898 language: "elixir".to_string(),
899 output: tool_output.display().to_string(),
900 })?;
901
902 let app_result = app::execute_codegen_unvalidated(CodegenRequest {
903 schema_path: schema,
904 schema_kind: SchemaKind::AsyncApi,
905 target: CodegenTargetKind::AsyncTestApp {
906 language: TargetLanguage::Elixir,
907 output: app_output.clone(),
908 },
909 dto: None,
910 })?;
911
912 match (tool_result, app_result) {
913 (CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
914 assert_eq!(tool_files.len(), 1);
915 assert_eq!(app_files.len(), 1);
916 let tool_code = std::fs::read_to_string(&tool_output)?;
917 let app_code = std::fs::read_to_string(&app_output)?;
918 assert_eq!(
919 collect_prefixed_lines(&tool_code, "defmodule AsyncApiTypes."),
920 collect_prefixed_lines(&app_code, "defmodule AsyncApiTypes.")
921 );
922 for expected in [
923 "defmodule AsyncApiFixtures do",
924 "defmodule AsyncApiTestClient do",
925 "def websocket_fixtures do",
926 ] {
927 assert!(tool_code.contains(expected), "tool output missing {expected}");
928 assert!(app_code.contains(expected), "app output missing {expected}");
929 }
930 }
931 _ => panic!("expected file-based AsyncAPI test app generation results"),
932 }
933
934 Ok(())
935 }
936
937 #[test]
938 fn test_validate_asyncapi_impl_matches_service() -> Result<()> {
939 let server = SpikardMcp::new();
940 let schema = repo_root().join("testing_data/schemas/chat-service.asyncapi.yaml");
941
942 let tool_result = server.validate_asyncapi_impl(ValidateAsyncapiParams {
943 schema: schema.display().to_string(),
944 })?;
945 let app_result = app::validate_asyncapi_schema(&schema)?;
946
947 assert_eq!(tool_result.title, app_result.title);
948 assert_eq!(tool_result.primary_protocol, app_result.primary_protocol);
949 assert_eq!(tool_result.channel_count, app_result.channel_count);
950 Ok(())
951 }
952
953 #[test]
954 fn test_init_project_impl_defaults_to_python_and_current_dir() -> Result<()> {
955 let server = SpikardMcp::new();
956 let tmp = TempDir::new()?;
957 let project_name = "mcp_default_init";
958
959 let response = server.init_project_impl(InitProjectParams {
960 name: project_name.to_string(),
961 language: None,
962 directory: Some(tmp.path().display().to_string()),
963 schema_path: None,
964 })?;
965
966 assert!(!response.files_created.is_empty());
967 let created_root = tmp.path().join(project_name);
968 assert!(created_root.exists(), "expected {} to exist", created_root.display());
969 assert!(
970 response
971 .files_created
972 .iter()
973 .any(|path| path.extension().is_some_and(|ext| ext == "py")),
974 "expected python project files"
975 );
976 Ok(())
977 }
978
979 #[test]
980 fn test_generate_openapi_impl_defaults_to_python() -> Result<()> {
981 let server = SpikardMcp::new();
982 let schema = repo_root().join("testing_data/schemas/todo-api.openapi.yaml");
983
984 let tool_result = server.generate_openapi_impl(GenerateOpenapiParams {
985 schema: schema.display().to_string(),
986 language: None,
987 output: None,
988 dto: None,
989 })?;
990
991 let app_result = app::execute_codegen(CodegenRequest {
992 schema_path: schema,
993 schema_kind: SchemaKind::OpenApi,
994 target: CodegenTargetKind::Server {
995 language: TargetLanguage::Python,
996 output: None,
997 },
998 dto: Some(DtoConfig::default()),
999 })?;
1000
1001 match (tool_result, app_result) {
1002 (CodegenOutcome::InMemory(tool_code), CodegenOutcome::InMemory(app_code)) => {
1003 assert_eq!(tool_code, app_code);
1004 }
1005 _ => panic!("expected in-memory OpenAPI generation results"),
1006 }
1007
1008 Ok(())
1009 }
1010
1011 #[test]
1012 fn test_parse_target_language_or_default_uses_python() {
1013 assert_eq!(
1014 parse_target_language_or_default(None, TargetLanguage::Python).unwrap(),
1015 TargetLanguage::Python
1016 );
1017 assert_eq!(
1018 parse_target_language_or_default(Some("ruby"), TargetLanguage::Python).unwrap(),
1019 TargetLanguage::Ruby
1020 );
1021 }
1022
1023 #[test]
1024 fn test_default_output_helpers_match_cli_conventions() {
1025 assert_eq!(
1026 default_graphql_output(TargetLanguage::Python),
1027 PathBuf::from("generated.py")
1028 );
1029 assert_eq!(
1030 default_graphql_output(TargetLanguage::TypeScript),
1031 PathBuf::from("generated.ts")
1032 );
1033 assert_eq!(
1034 default_jsonrpc_output(TargetLanguage::Python),
1035 PathBuf::from("handlers.py")
1036 );
1037 assert_eq!(
1038 default_jsonrpc_output(TargetLanguage::Elixir),
1039 PathBuf::from("handlers.ex")
1040 );
1041 }
1042
1043 #[test]
1044 fn test_generate_php_dto_impl_writes_files() -> Result<()> {
1045 let server = SpikardMcp::new();
1046 let tmp = TempDir::new()?;
1047
1048 let assets = server.generate_php_dto_impl(GeneratePhpDtoParams {
1049 output: Some(tmp.path().display().to_string()),
1050 })?;
1051
1052 assert!(assets.iter().any(|asset| asset.path.ends_with("Request.php")));
1053 assert!(assets.iter().any(|asset| asset.path.ends_with("Response.php")));
1054 Ok(())
1055 }
1056
1057 #[test]
1058 fn test_generate_php_dto_impl_matches_service() -> Result<()> {
1059 let server = SpikardMcp::new();
1060 let tool_tmp = TempDir::new()?;
1061 let app_tmp = TempDir::new()?;
1062
1063 let tool_result = server.generate_php_dto_impl(GeneratePhpDtoParams {
1064 output: Some(tool_tmp.path().display().to_string()),
1065 })?;
1066 let app_result = app::generate_php_dto(app_tmp.path())?;
1067
1068 assert_eq!(tool_result.len(), app_result.len());
1069 assert_eq!(read_file_map(tool_tmp.path())?, read_file_map(app_tmp.path())?);
1070 Ok(())
1071 }
1072
1073 #[test]
1074 fn test_get_features_matches_app_summary() -> Result<()> {
1075 let server = SpikardMcp::new();
1076 let response = server.get_features(Parameters(EmptyParams {}))?;
1077 let text = response
1078 .content
1079 .first()
1080 .and_then(|content| content.raw.as_text())
1081 .map(|content| content.text.as_str())
1082 .expect("expected text tool response");
1083 let summary = app::feature_summary();
1084 assert_eq!(text, serde_json::to_string_pretty(&summary)?);
1085 Ok(())
1086 }
1087
1088 #[test]
1089 fn test_init_project_impl_creates_files() -> Result<()> {
1090 let server = SpikardMcp::new();
1091 let tmp = TempDir::new()?;
1092
1093 let result = server.init_project_impl(InitProjectParams {
1094 name: "agent_demo".to_string(),
1095 language: Some("python".to_string()),
1096 directory: Some(tmp.path().display().to_string()),
1097 schema_path: None,
1098 })?;
1099
1100 assert!(!result.files_created.is_empty());
1101 assert!(!result.next_steps.is_empty());
1102 Ok(())
1103 }
1104
1105 #[test]
1106 fn test_init_project_impl_creates_expected_structures_for_each_binding() -> Result<()> {
1107 let server = SpikardMcp::new();
1108 let tmp = TempDir::new()?;
1109
1110 let cases = [
1111 (
1112 "python",
1113 "mcp_python_demo",
1114 vec![
1115 "pyproject.toml",
1116 "README.md",
1117 ".gitignore",
1118 "src/mcp_python_demo/__init__.py",
1119 "src/mcp_python_demo/app.py",
1120 "tests/test_app.py",
1121 ],
1122 ),
1123 (
1124 "typescript",
1125 "mcp-ts-demo",
1126 vec![
1127 "package.json",
1128 "tsconfig.json",
1129 "vitest.config.ts",
1130 ".gitignore",
1131 "README.md",
1132 "src/app.ts",
1133 "src/server.ts",
1134 "tests/app.spec.ts",
1135 ],
1136 ),
1137 (
1138 "rust",
1139 "mcp_rust_demo",
1140 vec![
1141 "Cargo.toml",
1142 "README.md",
1143 ".gitignore",
1144 "src/main.rs",
1145 "src/lib.rs",
1146 "tests/integration_test.rs",
1147 ],
1148 ),
1149 (
1150 "ruby",
1151 "mcp_ruby_demo",
1152 vec![
1153 "Gemfile",
1154 ".gitignore",
1155 "README.md",
1156 "bin/server",
1157 "lib/mcp_ruby_demo.rb",
1158 "sig/mcp_ruby_demo.rbs",
1159 "spec/mcp_ruby_demo_spec.rb",
1160 "spec/spec_helper.rb",
1161 ".rspec",
1162 "Rakefile",
1163 ],
1164 ),
1165 (
1166 "php",
1167 "mcp_php_demo",
1168 vec![
1169 "composer.json",
1170 "phpstan.neon",
1171 "phpunit.xml",
1172 ".gitignore",
1173 "README.md",
1174 "src/AppController.php",
1175 "bin/server.php",
1176 "tests/AppTest.php",
1177 ],
1178 ),
1179 (
1180 "elixir",
1181 "mcp_elixir_demo",
1182 vec![
1183 "mix.exs",
1184 ".formatter.exs",
1185 ".gitignore",
1186 "lib/mcp_elixir_demo.ex",
1187 "lib/mcp_elixir_demo/router.ex",
1188 "run.exs",
1189 "test/mcp_elixir_demo_test.exs",
1190 "test/test_helper.exs",
1191 ],
1192 ),
1193 ];
1194
1195 for (language, name, expected_paths) in cases {
1196 let result = server.init_project_impl(InitProjectParams {
1197 name: name.to_string(),
1198 language: Some(language.to_string()),
1199 directory: Some(tmp.path().display().to_string()),
1200 schema_path: None,
1201 })?;
1202
1203 assert!(!result.files_created.is_empty(), "expected {} files_created", language);
1204 assert!(!result.next_steps.is_empty(), "expected {} next_steps", language);
1205
1206 let project_dir = tmp.path().join(name);
1207 assert!(project_dir.exists(), "expected {} project root", language);
1208
1209 for expected in expected_paths {
1210 assert!(
1211 project_dir.join(expected).exists(),
1212 "expected {} to create {}",
1213 language,
1214 expected
1215 );
1216 }
1217 }
1218
1219 Ok(())
1220 }
1221}