use crate::{
app,
codegen::{CodegenOutcome, CodegenRequest, CodegenTargetKind, DtoConfig, SchemaKind, TargetLanguage},
init::InitRequest,
mcp::{
errors::map_app_error_to_mcp,
params::{
EmptyParams, GenerateAsyncapiBundleParams, GenerateAsyncapiFixturesParams, GenerateAsyncapiHandlersParams,
GenerateAsyncapiTestAppParams, GenerateGraphqlParams, GenerateJsonrpcParams, GenerateOpenapiParams,
GeneratePhpDtoParams, GenerateProtobufParams, GenerateSqlParams, InitProjectParams, ValidateAsyncapiParams,
},
},
};
use anyhow::Result;
use rmcp::{
ServerHandler, ServiceExt,
handler::server::{
router::tool::ToolRouter,
wrapper::{Json, Parameters},
},
model::*,
tool, tool_handler, tool_router,
transport::stdio,
};
use scythe_core::dialect::SqlDialect;
use spikard_codegen::sql::DecimalMode;
use std::path::PathBuf;
#[cfg(feature = "mcp-http")]
use rmcp::transport::streamable_http_server::{StreamableHttpService, session::local::LocalSessionManager};
#[derive(Clone)]
pub struct SpikardMcp {
#[allow(dead_code)]
tool_router: ToolRouter<SpikardMcp>,
}
impl SpikardMcp {
#[must_use]
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
fn init_project_impl(params: InitProjectParams) -> Result<crate::init::InitResponse, rmcp::ErrorData> {
let language = parse_target_language_or_default(params.language.as_deref(), TargetLanguage::Python)?;
let base_dir = params.directory.unwrap_or_else(|| ".".to_string());
let request = InitRequest {
project_name: params.name.clone(),
language,
project_dir: PathBuf::from(base_dir).join(¶ms.name),
schema_path: params.schema_path.map(PathBuf::from),
};
app::init_project(request).map_err(map_app_error_to_mcp)
}
fn generate_openapi_impl(params: GenerateOpenapiParams) -> Result<CodegenOutcome, rmcp::ErrorData> {
let language = parse_target_language_or_default(params.language.as_deref(), TargetLanguage::Python)?;
let mut dto = DtoConfig::default();
if let Some(dto_name) = params.dto.as_deref() {
apply_dto_choice(&mut dto, language, dto_name)?;
}
app::execute_codegen(CodegenRequest {
schema_path: PathBuf::from(params.schema),
schema_kind: SchemaKind::OpenApi,
target: CodegenTargetKind::Server {
language,
output: params.output.map(PathBuf::from),
},
dto: Some(dto),
})
.map_err(map_app_error_to_mcp)
}
fn generate_asyncapi_handlers_impl(
params: GenerateAsyncapiHandlersParams,
) -> Result<CodegenOutcome, rmcp::ErrorData> {
let language = parse_target_language(¶ms.language)?;
let mut dto = DtoConfig::default();
if let Some(dto_name) = params.dto.as_deref() {
apply_dto_choice(&mut dto, language, dto_name)?;
}
app::execute_codegen(CodegenRequest {
schema_path: PathBuf::from(params.schema),
schema_kind: SchemaKind::AsyncApi,
target: CodegenTargetKind::AsyncHandlers {
language,
output: PathBuf::from(params.output),
},
dto: Some(dto),
})
.map_err(map_app_error_to_mcp)
}
fn generate_jsonrpc_impl(params: GenerateJsonrpcParams) -> Result<CodegenOutcome, rmcp::ErrorData> {
let language = parse_target_language_or_default(params.language.as_deref(), TargetLanguage::Python)?;
app::execute_codegen(CodegenRequest {
schema_path: PathBuf::from(params.schema),
schema_kind: SchemaKind::OpenRpc,
target: CodegenTargetKind::JsonRpcHandlers {
language,
output: params
.output
.map(PathBuf::from)
.unwrap_or_else(|| default_jsonrpc_output(language)),
},
dto: None,
})
.map_err(map_app_error_to_mcp)
}
fn generate_graphql_impl(params: GenerateGraphqlParams) -> Result<CodegenOutcome, rmcp::ErrorData> {
let language = parse_target_language_or_default(params.language.as_deref(), TargetLanguage::Python)?;
let output = params
.output
.map(PathBuf::from)
.unwrap_or_else(|| default_graphql_output(language));
app::execute_codegen(CodegenRequest {
schema_path: PathBuf::from(params.schema),
schema_kind: SchemaKind::GraphQL,
target: CodegenTargetKind::GraphQL {
language,
output,
target: params.target.unwrap_or_else(|| "all".to_string()),
},
dto: None,
})
.map_err(map_app_error_to_mcp)
}
fn generate_protobuf_impl(params: GenerateProtobufParams) -> Result<CodegenOutcome, rmcp::ErrorData> {
let language = parse_target_language_or_default(params.language.as_deref(), TargetLanguage::Python)?;
app::execute_codegen(CodegenRequest {
schema_path: PathBuf::from(params.schema),
schema_kind: SchemaKind::Protobuf,
target: CodegenTargetKind::Protobuf {
language,
output: PathBuf::from(params.output),
target: params.target.unwrap_or_else(|| "all".to_string()),
include_paths: params
.include
.unwrap_or_default()
.into_iter()
.map(PathBuf::from)
.collect(),
},
dto: None,
})
.map_err(map_app_error_to_mcp)
}
fn generate_php_dto_impl(
params: GeneratePhpDtoParams,
) -> Result<Vec<crate::codegen::GeneratedAsset>, rmcp::ErrorData> {
let output = params.output.unwrap_or_else(|| "src/Generated".to_string());
app::generate_php_dto(PathBuf::from(output).as_path()).map_err(map_app_error_to_mcp)
}
fn generate_asyncapi_fixtures_impl(
params: GenerateAsyncapiFixturesParams,
) -> Result<CodegenOutcome, rmcp::ErrorData> {
app::execute_codegen_unvalidated(CodegenRequest {
schema_path: PathBuf::from(params.schema),
schema_kind: SchemaKind::AsyncApi,
target: CodegenTargetKind::AsyncFixtures {
output: PathBuf::from(params.output.unwrap_or_else(|| "testing_data".to_string())),
},
dto: None,
})
.map_err(map_app_error_to_mcp)
}
fn generate_asyncapi_test_app_impl(
params: GenerateAsyncapiTestAppParams,
) -> Result<CodegenOutcome, rmcp::ErrorData> {
let language = parse_target_language(¶ms.language)?;
app::execute_codegen_unvalidated(CodegenRequest {
schema_path: PathBuf::from(params.schema),
schema_kind: SchemaKind::AsyncApi,
target: CodegenTargetKind::AsyncTestApp {
language,
output: PathBuf::from(params.output),
},
dto: None,
})
.map_err(map_app_error_to_mcp)
}
fn generate_asyncapi_bundle_impl(params: GenerateAsyncapiBundleParams) -> Result<CodegenOutcome, rmcp::ErrorData> {
app::execute_codegen_unvalidated(CodegenRequest {
schema_path: PathBuf::from(params.schema),
schema_kind: SchemaKind::AsyncApi,
target: CodegenTargetKind::AsyncAll {
output: PathBuf::from(params.output.unwrap_or_else(|| ".".to_string())),
},
dto: None,
})
.map_err(map_app_error_to_mcp)
}
fn validate_asyncapi_impl(
params: ValidateAsyncapiParams,
) -> Result<app::AsyncApiValidationSummary, rmcp::ErrorData> {
app::validate_asyncapi_schema(PathBuf::from(params.schema).as_path()).map_err(map_app_error_to_mcp)
}
fn generate_sql_impl(params: GenerateSqlParams) -> Result<CodegenOutcome, rmcp::ErrorData> {
if params.schema.is_empty() {
return Err(rmcp::ErrorData::invalid_params(
"generate_sql requires at least one `schema` DDL path".to_string(),
None,
));
}
if params.lang.is_empty() {
return Err(rmcp::ErrorData::invalid_params(
"generate_sql requires at least one `lang` target language".to_string(),
None,
));
}
let languages = params
.lang
.iter()
.map(|language| parse_target_language(language))
.collect::<Result<Vec<_>, _>>()?;
let dialect = parse_sql_dialect(params.dialect.as_deref())?;
let decimal_mode = parse_decimal_mode(params.decimal_mode.as_deref())?;
app::execute_codegen(CodegenRequest {
schema_path: PathBuf::from(params.queries),
schema_kind: SchemaKind::Sql,
target: CodegenTargetKind::SqlHandlers {
schema_paths: params.schema.into_iter().map(PathBuf::from).collect(),
output: PathBuf::from(params.output.unwrap_or_else(|| "generated".to_string())),
dialect,
languages,
decimal_mode,
strict: params.strict.unwrap_or(false),
emit_openapi: !params.no_openapi.unwrap_or(false),
api_title: params.api_title.unwrap_or_else(|| "Generated API".to_string()),
api_version: params.api_version.unwrap_or_else(|| "0.1.0".to_string()),
},
dto: None,
})
.map_err(map_app_error_to_mcp)
}
}
#[tool_router]
impl SpikardMcp {
#[tool(
description = "Initialize a new Spikard project in the requested language and return the created files and next steps.",
annotations(
title = "Init Project",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false,
open_world_hint = false
)
)]
async fn init_project(
&self,
Parameters(params): Parameters<InitProjectParams>,
) -> Result<Json<crate::init::InitResponse>, rmcp::ErrorData> {
run_blocking(move || Self::init_project_impl(params)).await
}
#[tool(
description = "Generate Spikard server handlers from an OpenAPI schema.",
annotations(
title = "Generate OpenAPI",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn generate_openapi(
&self,
Parameters(params): Parameters<GenerateOpenapiParams>,
) -> Result<Json<CodegenOutcome>, rmcp::ErrorData> {
run_blocking(move || Self::generate_openapi_impl(params)).await
}
#[tool(
description = "Generate AsyncAPI handler scaffolding for a target language.",
annotations(
title = "Generate AsyncAPI Handlers",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn generate_asyncapi_handlers(
&self,
Parameters(params): Parameters<GenerateAsyncapiHandlersParams>,
) -> Result<Json<CodegenOutcome>, rmcp::ErrorData> {
run_blocking(move || Self::generate_asyncapi_handlers_impl(params)).await
}
#[tool(
description = "Generate JSON-RPC handlers from an OpenRPC schema.",
annotations(
title = "Generate JSON-RPC",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn generate_jsonrpc(
&self,
Parameters(params): Parameters<GenerateJsonrpcParams>,
) -> Result<Json<CodegenOutcome>, rmcp::ErrorData> {
run_blocking(move || Self::generate_jsonrpc_impl(params)).await
}
#[tool(
description = "Generate GraphQL types, resolvers, or schema definitions for a target language.",
annotations(
title = "Generate GraphQL",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn generate_graphql(
&self,
Parameters(params): Parameters<GenerateGraphqlParams>,
) -> Result<Json<CodegenOutcome>, rmcp::ErrorData> {
run_blocking(move || Self::generate_graphql_impl(params)).await
}
#[tool(
description = "Generate Protobuf messages and gRPC services for a target language.",
annotations(
title = "Generate Protobuf",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn generate_protobuf(
&self,
Parameters(params): Parameters<GenerateProtobufParams>,
) -> Result<Json<CodegenOutcome>, rmcp::ErrorData> {
run_blocking(move || Self::generate_protobuf_impl(params)).await
}
#[tool(
description = "Generate the PHP DTO classes used for Spikard integrations.",
annotations(
title = "Generate PHP DTO",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn generate_php_dto(
&self,
Parameters(params): Parameters<GeneratePhpDtoParams>,
) -> Result<Json<Vec<crate::codegen::GeneratedAsset>>, rmcp::ErrorData> {
run_blocking(move || Self::generate_php_dto_impl(params)).await
}
#[tool(
description = "Generate routes, an OpenAPI 3.1 spec, and a language sidecar from annotated SQL queries (via scythe).",
annotations(
title = "Generate SQL Handlers",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn generate_sql(
&self,
Parameters(params): Parameters<GenerateSqlParams>,
) -> Result<Json<CodegenOutcome>, rmcp::ErrorData> {
run_blocking(move || Self::generate_sql_impl(params)).await
}
#[tool(
description = "Generate AsyncAPI test fixtures used by Spikard's codegen-first testing flows.",
annotations(
title = "Generate AsyncAPI Fixtures",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn generate_asyncapi_fixtures(
&self,
Parameters(params): Parameters<GenerateAsyncapiFixturesParams>,
) -> Result<Json<CodegenOutcome>, rmcp::ErrorData> {
run_blocking(move || Self::generate_asyncapi_fixtures_impl(params)).await
}
#[tool(
description = "Generate a language-specific AsyncAPI test application.",
annotations(
title = "Generate AsyncAPI Test App",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn generate_asyncapi_test_app(
&self,
Parameters(params): Parameters<GenerateAsyncapiTestAppParams>,
) -> Result<Json<CodegenOutcome>, rmcp::ErrorData> {
run_blocking(move || Self::generate_asyncapi_test_app_impl(params)).await
}
#[tool(
description = "Generate AsyncAPI fixtures and test apps for all supported languages.",
annotations(
title = "Generate AsyncAPI Bundle",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn generate_asyncapi_bundle(
&self,
Parameters(params): Parameters<GenerateAsyncapiBundleParams>,
) -> Result<Json<CodegenOutcome>, rmcp::ErrorData> {
run_blocking(move || Self::generate_asyncapi_bundle_impl(params)).await
}
#[tool(
description = "Validate an AsyncAPI schema and return its protocol and channel summary.",
annotations(
title = "Validate AsyncAPI",
read_only_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn validate_asyncapi(
&self,
Parameters(params): Parameters<ValidateAsyncapiParams>,
) -> Result<Json<app::AsyncApiValidationSummary>, rmcp::ErrorData> {
run_blocking(move || Self::validate_asyncapi_impl(params)).await
}
#[tool(
description = "Return the current Spikard feature summary and binding installation hints.",
annotations(
title = "Get Features",
read_only_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn get_features(
&self,
Parameters(_): Parameters<EmptyParams>,
) -> Result<Json<app::FeatureSummary>, rmcp::ErrorData> {
Ok(Json(app::feature_summary()))
}
}
#[tool_handler]
impl ServerHandler for SpikardMcp {
fn get_info(&self) -> ServerInfo {
let mut capabilities = ServerCapabilities::default();
capabilities.tools = Some(ToolsCapability::default());
let server_info = Implementation::new("spikard-mcp", env!("CARGO_PKG_VERSION"))
.with_title("Spikard MCP Server")
.with_description(
"Codegen-first MCP server for project scaffolding, schema validation, and test-app generation.",
)
.with_website_url("https://spikard.dev/");
InitializeResult::new(capabilities)
.with_server_info(server_info)
.with_instructions(
"Use these tools to scaffold new Spikard projects, generate code from API schemas, validate AsyncAPI documents, and create fixture-driven test assets.",
)
}
}
impl Default for SpikardMcp {
fn default() -> Self {
Self::new()
}
}
pub async fn start_mcp_server() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let service = SpikardMcp::new().serve(stdio()).await?;
service.waiting().await?;
Ok(())
}
#[cfg(feature = "mcp-http")]
pub async fn start_mcp_server_http(
host: impl AsRef<str>,
port: u16,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use axum::Router;
use std::net::SocketAddr;
let http_service = StreamableHttpService::new(
|| Ok(SpikardMcp::new()),
LocalSessionManager::default().into(),
Default::default(),
);
let router = Router::new().nest_service("/mcp", http_service);
let addr: SocketAddr = format!("{}:{}", host.as_ref(), port)
.parse()
.map_err(|e| format!("Invalid address: {}", e))?;
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router).await?;
Ok(())
}
async fn run_blocking<T, F>(work: F) -> Result<Json<T>, rmcp::ErrorData>
where
T: Send + 'static,
F: FnOnce() -> Result<T, rmcp::ErrorData> + Send + 'static,
{
let value = tokio::task::spawn_blocking(work)
.await
.map_err(|error| rmcp::ErrorData::internal_error(format!("codegen task failed: {error}"), None))??;
Ok(Json(value))
}
fn parse_sql_dialect(dialect: Option<&str>) -> Result<SqlDialect, rmcp::ErrorData> {
match dialect.unwrap_or("postgresql").to_ascii_lowercase().as_str() {
"postgresql" | "postgres" | "redshift" | "cockroachdb" => Ok(SqlDialect::PostgreSQL),
"mysql" | "mariadb" => Ok(SqlDialect::MySQL),
"sqlite" => Ok(SqlDialect::SQLite),
"mssql" | "sqlserver" => Ok(SqlDialect::MsSql),
"oracle" => Ok(SqlDialect::Oracle),
"snowflake" => Ok(SqlDialect::Snowflake),
other => Err(rmcp::ErrorData::invalid_params(
format!("Unsupported SQL dialect '{other}'. Use postgresql, mysql, sqlite, mssql, oracle, or snowflake."),
None,
)),
}
}
fn parse_decimal_mode(mode: Option<&str>) -> Result<DecimalMode, rmcp::ErrorData> {
match mode.unwrap_or("string-pattern").to_ascii_lowercase().as_str() {
"string-pattern" | "string_pattern" => Ok(DecimalMode::StringPattern),
"number" => Ok(DecimalMode::Number),
other => Err(rmcp::ErrorData::invalid_params(
format!("Unsupported decimal mode '{other}'. Use string-pattern or number."),
None,
)),
}
}
fn parse_target_language(language: &str) -> Result<TargetLanguage, rmcp::ErrorData> {
match language.to_ascii_lowercase().as_str() {
"python" => Ok(TargetLanguage::Python),
"typescript" => Ok(TargetLanguage::TypeScript),
"rust" => Ok(TargetLanguage::Rust),
"ruby" => Ok(TargetLanguage::Ruby),
"php" => Ok(TargetLanguage::Php),
"elixir" => Ok(TargetLanguage::Elixir),
other => Err(rmcp::ErrorData::invalid_params(
format!(
"Unsupported language '{}'. Use python, typescript, rust, ruby, php, or elixir.",
other
),
None,
)),
}
}
fn parse_target_language_or_default(
language: Option<&str>,
default: TargetLanguage,
) -> Result<TargetLanguage, rmcp::ErrorData> {
match language {
Some(language) => parse_target_language(language),
None => Ok(default),
}
}
fn apply_dto_choice(config: &mut DtoConfig, language: TargetLanguage, dto: &str) -> Result<(), rmcp::ErrorData> {
match (language, dto.to_ascii_lowercase().as_str()) {
(TargetLanguage::Python, "dataclass") => {
config.python = crate::codegen::PythonDtoStyle::Dataclass;
Ok(())
}
(TargetLanguage::Python, "msgspec") => {
config.python = crate::codegen::PythonDtoStyle::Msgspec;
Ok(())
}
(TargetLanguage::TypeScript, "zod") => {
config.node = crate::codegen::NodeDtoStyle::Zod;
Ok(())
}
(TargetLanguage::Ruby, "dryschema") | (TargetLanguage::Ruby, "dry_schema") => {
config.ruby = crate::codegen::RubyDtoStyle::DrySchema;
Ok(())
}
(TargetLanguage::Rust, "serde") => {
config.rust = crate::codegen::RustDtoStyle::SerdeStruct;
Ok(())
}
(TargetLanguage::Php, "readonlyclass") | (TargetLanguage::Php, "readonly_class") => {
config.php = crate::codegen::PhpDtoStyle::ReadonlyClass;
Ok(())
}
_ => Err(rmcp::ErrorData::invalid_params(
format!("DTO '{}' is not supported for {:?}", dto, language),
None,
)),
}
}
fn default_graphql_output(language: TargetLanguage) -> PathBuf {
let ext = match language {
TargetLanguage::Python => "py",
TargetLanguage::TypeScript => "ts",
TargetLanguage::Rust => "rs",
TargetLanguage::Ruby => "rb",
TargetLanguage::Php => "php",
TargetLanguage::Elixir => "ex",
};
PathBuf::from(format!("generated.{ext}"))
}
fn default_jsonrpc_output(language: TargetLanguage) -> PathBuf {
let ext = match language {
TargetLanguage::Python => "py",
TargetLanguage::TypeScript => "ts",
TargetLanguage::Rust => "rs",
TargetLanguage::Ruby => "rb",
TargetLanguage::Php => "php",
TargetLanguage::Elixir => "ex",
};
PathBuf::from(format!("handlers.{ext}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codegen::{SchemaKind, TargetLanguage};
use std::collections::BTreeMap;
use tempfile::TempDir;
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("CARGO_MANIFEST_DIR should be crates/spikard-cli")
.to_path_buf()
}
fn write_temp_graphql_schema(tmp: &TempDir) -> Result<PathBuf> {
let schema = tmp.path().join("schema.graphql");
std::fs::write(
&schema,
"type Query {\n hello: String!\n}\n\ntype User {\n id: ID!\n name: String!\n}\n",
)?;
Ok(schema)
}
fn read_file_map(root: &std::path::Path) -> Result<BTreeMap<PathBuf, String>> {
fn walk_dir(
root: &std::path::Path,
dir: &std::path::Path,
files: &mut BTreeMap<PathBuf, String>,
) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
walk_dir(root, &path, files)?;
continue;
}
files.insert(path.strip_prefix(root)?.to_path_buf(), std::fs::read_to_string(&path)?);
}
Ok(())
}
let mut files = BTreeMap::new();
walk_dir(root, root, &mut files)?;
Ok(files)
}
fn collect_prefixed_lines(text: &str, prefix: &str) -> Vec<String> {
let mut lines = text
.lines()
.filter(|line| line.starts_with(prefix))
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
lines.sort();
lines
}
#[test]
fn test_tool_router_has_expected_routes() {
let router = SpikardMcp::tool_router();
let expected = [
"init_project",
"generate_openapi",
"generate_asyncapi_handlers",
"generate_jsonrpc",
"generate_graphql",
"generate_protobuf",
"generate_php_dto",
"generate_sql",
"generate_asyncapi_fixtures",
"generate_asyncapi_test_app",
"generate_asyncapi_bundle",
"validate_asyncapi",
"get_features",
];
for route in expected {
assert!(router.has_route(route), "missing route {route}");
}
assert_eq!(router.list_all().len(), expected.len());
}
#[test]
fn test_server_info() {
let info = SpikardMcp::new().get_info();
assert_eq!(info.server_info.name, "spikard-mcp");
assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
assert!(info.capabilities.tools.is_some());
}
#[test]
fn test_generate_openapi_impl_matches_service() -> Result<()> {
let schema = repo_root().join("testing_data/schemas/todo-api.openapi.yaml");
let tool_result = SpikardMcp::generate_openapi_impl(GenerateOpenapiParams {
schema: schema.display().to_string(),
language: Some("python".to_string()),
output: None,
dto: Some("dataclass".to_string()),
})?;
let mut dto = DtoConfig::default();
dto.python = crate::codegen::PythonDtoStyle::Dataclass;
let app_result = app::execute_codegen(CodegenRequest {
schema_path: schema,
schema_kind: SchemaKind::OpenApi,
target: CodegenTargetKind::Server {
language: TargetLanguage::Python,
output: None,
},
dto: Some(dto),
})?;
match (tool_result, app_result) {
(CodegenOutcome::InMemory(tool_code), CodegenOutcome::InMemory(app_code)) => {
assert_eq!(tool_code, app_code);
}
_ => panic!("expected in-memory code generation results"),
}
Ok(())
}
#[test]
fn test_generate_jsonrpc_impl_matches_service() -> Result<()> {
let schema = repo_root().join("testing_data/schemas/user-api.openrpc.json");
let tmp = TempDir::new()?;
let tool_output = tmp.path().join("tool_handlers.py");
let app_output = tmp.path().join("app_handlers.py");
let tool_result = SpikardMcp::generate_jsonrpc_impl(GenerateJsonrpcParams {
schema: schema.display().to_string(),
language: Some("python".to_string()),
output: Some(tool_output.display().to_string()),
})?;
let app_result = app::execute_codegen(CodegenRequest {
schema_path: schema,
schema_kind: SchemaKind::OpenRpc,
target: CodegenTargetKind::JsonRpcHandlers {
language: TargetLanguage::Python,
output: app_output.clone(),
},
dto: None,
})?;
match (tool_result, app_result) {
(CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
assert_eq!(tool_files.len(), 1);
assert_eq!(app_files.len(), 1);
assert_eq!(
std::fs::read_to_string(&tool_output)?,
std::fs::read_to_string(&app_output)?
);
}
_ => panic!("expected file-based JSON-RPC generation results"),
}
Ok(())
}
#[test]
fn test_generate_graphql_impl_matches_service() -> Result<()> {
let tmp = TempDir::new()?;
let schema = write_temp_graphql_schema(&tmp)?;
let tool_output = tmp.path().join("tool_generated.py");
let app_output = tmp.path().join("app_generated.py");
let tool_result = SpikardMcp::generate_graphql_impl(GenerateGraphqlParams {
schema: schema.display().to_string(),
language: Some("python".to_string()),
output: Some(tool_output.display().to_string()),
target: Some("all".to_string()),
})?;
let app_result = app::execute_codegen(CodegenRequest {
schema_path: schema,
schema_kind: SchemaKind::GraphQL,
target: CodegenTargetKind::GraphQL {
language: TargetLanguage::Python,
output: app_output.clone(),
target: "all".to_string(),
},
dto: None,
})?;
match (tool_result, app_result) {
(CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
assert_eq!(tool_files.len(), 1);
assert_eq!(app_files.len(), 1);
assert_eq!(
std::fs::read_to_string(&tool_output)?,
std::fs::read_to_string(&app_output)?
);
}
_ => panic!("expected file-based GraphQL generation results"),
}
Ok(())
}
#[test]
fn test_generate_protobuf_impl_matches_service() -> Result<()> {
let schema = repo_root().join("testing_data/schemas/user-service.proto");
let tmp = TempDir::new()?;
let tool_output = tmp.path().join("tool_generated.ts");
let app_output = tmp.path().join("app_generated.ts");
let tool_result = SpikardMcp::generate_protobuf_impl(GenerateProtobufParams {
schema: schema.display().to_string(),
language: Some("typescript".to_string()),
output: tool_output.display().to_string(),
target: Some("all".to_string()),
include: None,
})?;
let app_result = app::execute_codegen(CodegenRequest {
schema_path: schema,
schema_kind: SchemaKind::Protobuf,
target: CodegenTargetKind::Protobuf {
language: TargetLanguage::TypeScript,
output: app_output.clone(),
target: "all".to_string(),
include_paths: Vec::new(),
},
dto: None,
})?;
match (tool_result, app_result) {
(CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
assert_eq!(tool_files.len(), 1);
assert_eq!(app_files.len(), 1);
let tool_code = std::fs::read_to_string(&tool_output)?;
let app_code = std::fs::read_to_string(&app_output)?;
for expected in [
"export interface User",
"export interface GetUserRequest",
"export interface UserResponse",
"export class UserServiceService",
] {
assert!(tool_code.contains(expected), "tool output missing {expected}");
assert!(app_code.contains(expected), "app output missing {expected}");
}
assert_eq!(
tool_code.matches("export interface").count(),
app_code.matches("export interface").count()
);
assert_eq!(
tool_code.matches("export enum").count(),
app_code.matches("export enum").count()
);
}
_ => panic!("expected file-based Protobuf generation results"),
}
Ok(())
}
#[test]
fn test_generate_asyncapi_bundle_impl_matches_service_asset_count() -> Result<()> {
let schema = repo_root().join("testing_data/schemas/chat-service.asyncapi.yaml");
let tool_tmp = TempDir::new()?;
let app_tmp = TempDir::new()?;
let tool_result = SpikardMcp::generate_asyncapi_bundle_impl(GenerateAsyncapiBundleParams {
schema: schema.display().to_string(),
output: Some(tool_tmp.path().display().to_string()),
})?;
let app_result = app::execute_codegen_unvalidated(CodegenRequest {
schema_path: schema,
schema_kind: SchemaKind::AsyncApi,
target: CodegenTargetKind::AsyncAll {
output: app_tmp.path().to_path_buf(),
},
dto: None,
})?;
match (tool_result, app_result) {
(CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
assert_eq!(tool_files.len(), app_files.len());
assert!(tool_files.len() >= 30, "expected fixtures plus six test apps");
}
_ => panic!("expected file-based AsyncAPI bundle results"),
}
Ok(())
}
#[test]
fn test_generate_asyncapi_handlers_impl_matches_service() -> Result<()> {
let schema = repo_root().join("testing_data/schemas/chat-service.asyncapi.yaml");
let tmp = TempDir::new()?;
let tool_output = tmp.path().join("tool_handlers.py");
let app_output = tmp.path().join("app_handlers.py");
let tool_result = SpikardMcp::generate_asyncapi_handlers_impl(GenerateAsyncapiHandlersParams {
schema: schema.display().to_string(),
language: "python".to_string(),
output: tool_output.display().to_string(),
dto: None,
})?;
let app_result = app::execute_codegen(CodegenRequest {
schema_path: schema,
schema_kind: SchemaKind::AsyncApi,
target: CodegenTargetKind::AsyncHandlers {
language: TargetLanguage::Python,
output: app_output.clone(),
},
dto: Some(DtoConfig::default()),
})?;
match (tool_result, app_result) {
(CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
assert_eq!(tool_files.len(), 1);
assert_eq!(app_files.len(), 1);
let tool_code = std::fs::read_to_string(&tool_output)?;
let app_code = std::fs::read_to_string(&app_output)?;
assert_eq!(
collect_prefixed_lines(&tool_code, "class "),
collect_prefixed_lines(&app_code, "class ")
);
assert!(tool_code.contains("@websocket(\"/chat/{roomId}\")"));
assert!(app_code.contains("@websocket(\"/chat/{roomId}\")"));
assert!(tool_code.contains("parsed: ChatMessage = msgspec.convert(message, type=ChatMessage)"));
assert!(app_code.contains("parsed: ChatMessage = msgspec.convert(message, type=ChatMessage)"));
}
_ => panic!("expected file-based AsyncAPI handler generation results"),
}
Ok(())
}
#[test]
fn test_generate_asyncapi_fixtures_impl_matches_service() -> Result<()> {
let schema = repo_root().join("testing_data/schemas/chat-service.asyncapi.yaml");
let tool_tmp = TempDir::new()?;
let app_tmp = TempDir::new()?;
let tool_result = SpikardMcp::generate_asyncapi_fixtures_impl(GenerateAsyncapiFixturesParams {
schema: schema.display().to_string(),
output: Some(tool_tmp.path().display().to_string()),
})?;
let app_result = app::execute_codegen_unvalidated(CodegenRequest {
schema_path: schema,
schema_kind: SchemaKind::AsyncApi,
target: CodegenTargetKind::AsyncFixtures {
output: app_tmp.path().to_path_buf(),
},
dto: None,
})?;
match (tool_result, app_result) {
(CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
assert_eq!(tool_files.len(), app_files.len());
assert_eq!(read_file_map(tool_tmp.path())?, read_file_map(app_tmp.path())?);
}
_ => panic!("expected file-based AsyncAPI fixture generation results"),
}
Ok(())
}
#[test]
fn test_generate_asyncapi_test_app_impl_matches_service() -> Result<()> {
let schema = repo_root().join("testing_data/schemas/chat-service.asyncapi.yaml");
let tmp = TempDir::new()?;
let tool_output = tmp.path().join("tool_app.ex");
let app_output = tmp.path().join("app_app.ex");
let tool_result = SpikardMcp::generate_asyncapi_test_app_impl(GenerateAsyncapiTestAppParams {
schema: schema.display().to_string(),
language: "elixir".to_string(),
output: tool_output.display().to_string(),
})?;
let app_result = app::execute_codegen_unvalidated(CodegenRequest {
schema_path: schema,
schema_kind: SchemaKind::AsyncApi,
target: CodegenTargetKind::AsyncTestApp {
language: TargetLanguage::Elixir,
output: app_output.clone(),
},
dto: None,
})?;
match (tool_result, app_result) {
(CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
assert_eq!(tool_files.len(), 1);
assert_eq!(app_files.len(), 1);
let tool_code = std::fs::read_to_string(&tool_output)?;
let app_code = std::fs::read_to_string(&app_output)?;
assert_eq!(
collect_prefixed_lines(&tool_code, "defmodule AsyncApiTypes."),
collect_prefixed_lines(&app_code, "defmodule AsyncApiTypes.")
);
for expected in [
"defmodule AsyncApiFixtures do",
"defmodule AsyncApiTestClient do",
"def websocket_fixtures do",
] {
assert!(tool_code.contains(expected), "tool output missing {expected}");
assert!(app_code.contains(expected), "app output missing {expected}");
}
}
_ => panic!("expected file-based AsyncAPI test app generation results"),
}
Ok(())
}
#[test]
fn test_validate_asyncapi_impl_matches_service() -> Result<()> {
let schema = repo_root().join("testing_data/schemas/chat-service.asyncapi.yaml");
let tool_result = SpikardMcp::validate_asyncapi_impl(ValidateAsyncapiParams {
schema: schema.display().to_string(),
})?;
let app_result = app::validate_asyncapi_schema(&schema)?;
assert_eq!(tool_result.title, app_result.title);
assert_eq!(tool_result.primary_protocol, app_result.primary_protocol);
assert_eq!(tool_result.channel_count, app_result.channel_count);
Ok(())
}
#[test]
fn test_init_project_impl_defaults_to_python_and_current_dir() -> Result<()> {
let tmp = TempDir::new()?;
let project_name = "mcp_default_init";
let response = SpikardMcp::init_project_impl(InitProjectParams {
name: project_name.to_string(),
language: None,
directory: Some(tmp.path().display().to_string()),
schema_path: None,
})?;
assert!(!response.files_created.is_empty());
let created_root = tmp.path().join(project_name);
assert!(created_root.exists(), "expected {} to exist", created_root.display());
assert!(
response
.files_created
.iter()
.any(|path| path.extension().is_some_and(|ext| ext == "py")),
"expected python project files"
);
Ok(())
}
#[test]
fn test_generate_openapi_impl_defaults_to_python() -> Result<()> {
let schema = repo_root().join("testing_data/schemas/todo-api.openapi.yaml");
let tool_result = SpikardMcp::generate_openapi_impl(GenerateOpenapiParams {
schema: schema.display().to_string(),
language: None,
output: None,
dto: None,
})?;
let app_result = app::execute_codegen(CodegenRequest {
schema_path: schema,
schema_kind: SchemaKind::OpenApi,
target: CodegenTargetKind::Server {
language: TargetLanguage::Python,
output: None,
},
dto: Some(DtoConfig::default()),
})?;
match (tool_result, app_result) {
(CodegenOutcome::InMemory(tool_code), CodegenOutcome::InMemory(app_code)) => {
assert_eq!(tool_code, app_code);
}
_ => panic!("expected in-memory OpenAPI generation results"),
}
Ok(())
}
#[test]
fn test_parse_target_language_or_default_uses_python() {
assert_eq!(
parse_target_language_or_default(None, TargetLanguage::Python).unwrap(),
TargetLanguage::Python
);
assert_eq!(
parse_target_language_or_default(Some("ruby"), TargetLanguage::Python).unwrap(),
TargetLanguage::Ruby
);
}
#[test]
fn test_default_output_helpers_match_cli_conventions() {
assert_eq!(
default_graphql_output(TargetLanguage::Python),
PathBuf::from("generated.py")
);
assert_eq!(
default_graphql_output(TargetLanguage::TypeScript),
PathBuf::from("generated.ts")
);
assert_eq!(
default_jsonrpc_output(TargetLanguage::Python),
PathBuf::from("handlers.py")
);
assert_eq!(
default_jsonrpc_output(TargetLanguage::Elixir),
PathBuf::from("handlers.ex")
);
}
#[test]
fn test_generate_php_dto_impl_writes_files() -> Result<()> {
let tmp = TempDir::new()?;
let assets = SpikardMcp::generate_php_dto_impl(GeneratePhpDtoParams {
output: Some(tmp.path().display().to_string()),
})?;
assert!(assets.iter().any(|asset| asset.path.ends_with("Request.php")));
assert!(assets.iter().any(|asset| asset.path.ends_with("Response.php")));
Ok(())
}
#[test]
fn test_generate_php_dto_impl_matches_service() -> Result<()> {
let tool_tmp = TempDir::new()?;
let app_tmp = TempDir::new()?;
let tool_result = SpikardMcp::generate_php_dto_impl(GeneratePhpDtoParams {
output: Some(tool_tmp.path().display().to_string()),
})?;
let app_result = app::generate_php_dto(app_tmp.path())?;
assert_eq!(tool_result.len(), app_result.len());
assert_eq!(read_file_map(tool_tmp.path())?, read_file_map(app_tmp.path())?);
Ok(())
}
#[test]
fn test_generate_sql_impl_matches_service() -> Result<()> {
let tmp = TempDir::new()?;
let schema = tmp.path().join("schema.sql");
std::fs::write(
&schema,
"CREATE TABLE users (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL);",
)?;
let queries_dir = tmp.path().join("queries");
std::fs::create_dir_all(&queries_dir)?;
std::fs::write(
queries_dir.join("users.sql"),
"-- @name GetUser\n-- @returns :one\n-- @http GET /users/{id}\nSELECT id, email FROM users WHERE id = $1;",
)?;
let tool_out = tmp.path().join("tool_out");
let app_out = tmp.path().join("app_out");
let tool_result = SpikardMcp::generate_sql_impl(GenerateSqlParams {
queries: queries_dir.display().to_string(),
schema: vec![schema.display().to_string()],
lang: vec!["python".to_string()],
dialect: Some("postgresql".to_string()),
output: Some(tool_out.display().to_string()),
decimal_mode: None,
strict: None,
no_openapi: None,
api_title: None,
api_version: None,
})?;
let app_result = app::execute_codegen(CodegenRequest {
schema_path: queries_dir.clone(),
schema_kind: SchemaKind::Sql,
target: CodegenTargetKind::SqlHandlers {
schema_paths: vec![schema.clone()],
output: app_out.clone(),
dialect: SqlDialect::PostgreSQL,
languages: vec![TargetLanguage::Python],
decimal_mode: DecimalMode::StringPattern,
strict: false,
emit_openapi: true,
api_title: "Generated API".to_string(),
api_version: "0.1.0".to_string(),
},
dto: None,
})?;
match (tool_result, app_result) {
(CodegenOutcome::Files(tool_files), CodegenOutcome::Files(app_files)) => {
assert!(!tool_files.is_empty(), "expected generated SQL handler files");
assert_eq!(tool_files.len(), app_files.len());
}
_ => panic!("expected file generation results for SQL handlers"),
}
Ok(())
}
#[tokio::test]
async fn test_get_features_matches_app_summary() -> Result<()> {
let Json(response) = SpikardMcp::new().get_features(Parameters(EmptyParams {})).await?;
assert_eq!(
serde_json::to_value(&response)?,
serde_json::to_value(app::feature_summary())?
);
Ok(())
}
#[test]
fn test_init_project_impl_creates_files() -> Result<()> {
let tmp = TempDir::new()?;
let result = SpikardMcp::init_project_impl(InitProjectParams {
name: "agent_demo".to_string(),
language: Some("python".to_string()),
directory: Some(tmp.path().display().to_string()),
schema_path: None,
})?;
assert!(!result.files_created.is_empty());
assert!(!result.next_steps.is_empty());
Ok(())
}
#[test]
fn test_init_project_impl_creates_expected_structures_for_each_binding() -> Result<()> {
let tmp = TempDir::new()?;
let cases = [
(
"python",
"mcp_python_demo",
vec![
"pyproject.toml",
"README.md",
".gitignore",
"src/mcp_python_demo/__init__.py",
"src/mcp_python_demo/app.py",
"tests/test_app.py",
],
),
(
"typescript",
"mcp-ts-demo",
vec![
"package.json",
"tsconfig.json",
"vitest.config.ts",
".gitignore",
"README.md",
"src/app.ts",
"src/server.ts",
"tests/app.spec.ts",
],
),
(
"rust",
"mcp_rust_demo",
vec![
"Cargo.toml",
"README.md",
".gitignore",
"src/main.rs",
"src/lib.rs",
"tests/integration_test.rs",
],
),
(
"ruby",
"mcp_ruby_demo",
vec![
"Gemfile",
".gitignore",
"README.md",
"bin/server",
"lib/mcp_ruby_demo.rb",
"sig/mcp_ruby_demo.rbs",
"spec/mcp_ruby_demo_spec.rb",
"spec/spec_helper.rb",
".rspec",
"Rakefile",
],
),
(
"php",
"mcp_php_demo",
vec![
"composer.json",
"phpstan.neon",
"phpunit.xml",
".gitignore",
"README.md",
"src/AppController.php",
"bin/server.php",
"tests/AppTest.php",
],
),
(
"elixir",
"mcp_elixir_demo",
vec![
"mix.exs",
".formatter.exs",
".gitignore",
"lib/mcp_elixir_demo.ex",
"lib/mcp_elixir_demo/router.ex",
"run.exs",
"test/mcp_elixir_demo_test.exs",
"test/test_helper.exs",
],
),
];
for (language, name, expected_paths) in cases {
let result = SpikardMcp::init_project_impl(InitProjectParams {
name: name.to_string(),
language: Some(language.to_string()),
directory: Some(tmp.path().display().to_string()),
schema_path: None,
})?;
assert!(!result.files_created.is_empty(), "expected {} files_created", language);
assert!(!result.next_steps.is_empty(), "expected {} next_steps", language);
let project_dir = tmp.path().join(name);
assert!(project_dir.exists(), "expected {} project root", language);
for expected in expected_paths {
assert!(
project_dir.join(expected).exists(),
"expected {} to create {}",
language,
expected
);
}
}
Ok(())
}
}