Skip to main content

spikard_cli/mcp/
server.rs

1//! Spikard MCP server implementation.
2
3use 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/// MCP server for Spikard's codegen-first workflows.
30#[derive(Clone)]
31pub struct SpikardMcp {
32    tool_router: ToolRouter<SpikardMcp>,
33}
34
35impl SpikardMcp {
36    /// Create a new MCP server instance.
37    #[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(&params.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(&params.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(&params.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    /// Initialize a new Spikard project scaffold.
224    #[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    /// Generate OpenAPI server handlers.
236    #[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    /// Generate AsyncAPI handler scaffolding.
248    #[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    /// Generate JSON-RPC handlers from an OpenRPC schema.
260    #[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    /// Generate GraphQL code.
272    #[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    /// Generate Protobuf code.
284    #[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    /// Generate PHP DTO helper classes.
296    #[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    /// Generate AsyncAPI fixtures.
308    #[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    /// Generate an AsyncAPI test application.
320    #[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    /// Generate the full AsyncAPI fixture and app bundle.
332    #[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    /// Validate an AsyncAPI schema and return the summary.
344    #[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    /// Return the current feature summary.
356    #[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
392/// Start the MCP server over stdio.
393pub 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/// Start the MCP server over HTTP stream transport.
400#[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}