Skip to main content
Glama

Convex MCP server

Official
by get-convex
analyze.rs19.5 kB
use std::{ collections::BTreeMap, str::FromStr, }; use common::{ components::ComponentId, types::{ ModuleEnvironment, RoutableMethod, UdfType, }, }; use errors::ErrorMetadataAnyhowExt; use keybroker::{ Identity, DEV_INSTANCE_NAME, }; use maplit::btreemap; use model::{ config::types::ModuleConfig, cron_jobs::types::{ CronIdentifier, CronSchedule, CronSpec, }, modules::{ function_validators::{ ArgsValidator, ReturnsValidator, }, module_versions::{ AnalyzedFunction, AnalyzedSourcePosition, Visibility, }, ModuleModel, }, udf_config::types::UdfConfig, }; use pretty_assertions::assert_eq; use runtime::testing::TestRuntime; use value::{ assert_obj, ConvexArray, ConvexValue, }; use crate::test_helpers::UdfTest; #[convex_macro::test_runtime] async fn test_analyze_module(rt: TestRuntime) -> anyhow::Result<()> { let t = UdfTest::default(rt).await?; let modules = { let mut tx = t.database.begin(Identity::system()).await?; ModuleModel::new(&mut tx) .get_application_modules(ComponentId::test_user(), t.module_loader.as_ref()) .await? }; let has_http = { let mut tx = t.database.begin(Identity::system()).await?; ModuleModel::new(&mut tx) .has_http(ComponentId::test_user()) .await? }; assert!(has_http); let udf_config = UdfConfig::new_for_test(&t.rt, "1000.0.0".parse()?); let mut result = t .isolate .analyze( udf_config.clone(), modules, BTreeMap::new(), DEV_INSTANCE_NAME.to_string(), ) .await??; let analyze_path = "analyze.js".parse()?; let module = result.remove(&analyze_path).unwrap(); let expected = [ // name, expected_type, mapped_lineno ("g", UdfType::Mutation, 8), ("f1", UdfType::Mutation, 19), ("f2", UdfType::Mutation, 19), ("default", UdfType::Query, 20), ("h", UdfType::Query, 20), ("action_in_v8", UdfType::Action, 28), ]; assert_eq!(module.functions.len(), expected.len()); assert!(module.source_index.is_some()); for (i, (name, expected_type, mapped_lineno)) in expected.iter().enumerate() { let function = &module.functions[i]; assert_eq!(&function.name[..], *name); assert_eq!(&function.udf_type, expected_type); assert_eq!(function.pos.as_ref().unwrap().start_lineno, *mapped_lineno); } let http_path = "http.js".parse()?; let module = result.remove(&http_path).unwrap(); let expected = vec![ // name, expected_type, mapped_lineno ("erroringQuery", UdfType::Query, 37), ]; assert_eq!(module.functions.len(), expected.len()); let expected_routes_unmapped = vec![ ("/imported", RoutableMethod::Get), ("/separate_function", RoutableMethod::Get), ("/inline", RoutableMethod::Get), ]; let expected_routes_mapped = vec![ ("/imported", RoutableMethod::Get, None), ( "/separate_function", RoutableMethod::Get, Some(AnalyzedSourcePosition { path: "http.js".parse()?, start_lineno: 12, start_col: 8, }), ), ( "/inline", RoutableMethod::Get, Some(AnalyzedSourcePosition { path: "http.js".parse()?, start_lineno: 26, start_col: 14, }), ), ]; assert_eq!( module .http_routes .as_ref() .expect("no analyzed http_routes found") .len(), expected_routes_unmapped.len() ); assert!(module.source_index.is_some()); for (i, (path, method)) in expected_routes_unmapped.iter().enumerate() { let route = &module.http_routes.as_ref().unwrap()[i]; assert_eq!(&route.route.path, path); assert_eq!(&route.route.method, method); } for (i, (path, method, mapped_pos)) in expected_routes_mapped.iter().enumerate() { let mapped_route = &module .http_routes .as_ref() .expect("no mapped http_routes found")[i]; assert_eq!(&mapped_route.route.path, path); assert_eq!(&mapped_route.route.method, method); assert_eq!(mapped_route.pos.as_ref(), mapped_pos.as_ref()); } let crons_path = "crons.js".parse()?; let module = result.remove(&crons_path).unwrap(); let arg = assert_obj!( "x" => ConvexValue::Float64(1.0) ); let args: ConvexArray = vec![ConvexValue::Object(arg)].try_into()?; assert_eq!( module.cron_specs, Some(btreemap!( CronIdentifier::from_str("weekly re-engagement email")? => CronSpec { udf_path: "crons.js:addOne".parse()?, udf_args: args.clone(), cron_schedule: CronSchedule::Weekly { day_of_week: 2, hour_utc: 17, minute_utc: 30 }}, CronIdentifier::from_str("add one every hour")? => CronSpec { udf_path: "crons.js:addOne".parse()?, udf_args: args.clone(), cron_schedule: CronSchedule::Interval{ seconds: 3600 * 24 * 7 } }, CronIdentifier::from_str("clear presence data")? => CronSpec { udf_path: "crons.js:addOne".parse()?, udf_args: args, cron_schedule: CronSchedule::Interval{ seconds: 300} }, ).into()), ); Ok(()) } #[convex_macro::test_runtime] async fn test_analyze_http_errors(rt: TestRuntime) -> anyhow::Result<()> { let cases = [ // No default export in http.js ("http_no_default.js", "must have a default export"), // default export is not an object (TODO) ( "http_undefined_default.js", "The default export of `convex/http.js` is not a Router.", ), // default export is not a router ( "http_object_default.js", "The default export of `convex/http.js` is not a Router.", ), ]; let t = UdfTest::default(rt).await?; for (file, expected_error) in cases { let mut modules = { let mut tx = t.database.begin(Identity::system()).await?; ModuleModel::new(&mut tx) .get_application_modules(ComponentId::test_user(), t.module_loader.as_ref()) .await? }; // Analyze this file as though it were the router (normally http.js) let test_http_canonical = file.parse()?; let real_http = "http.js".parse()?; modules.remove(&real_http).unwrap(); let test_http_module: ModuleConfig = modules.remove(&test_http_canonical).unwrap(); // stick in an `is_http: true` module with the name of the module we're testing let with_http = test_http_canonical.with_http(); modules.insert(with_http, test_http_module.clone()); // reinsert the original module so it's not missing modules.insert(test_http_canonical, test_http_module); let udf_config = UdfConfig::new_for_test(&t.rt, "1000.0.0".parse()?); let Err(err) = t .isolate .analyze( udf_config, modules, BTreeMap::new(), DEV_INSTANCE_NAME.to_string(), ) .await? else { anyhow::bail!("No JsError raised for missing default export"); }; assert!(format!("{err}").contains(expected_error), "{err:?}"); } Ok(()) } #[convex_macro::test_runtime] async fn test_analyze_function(rt: TestRuntime) -> anyhow::Result<()> { let t = UdfTest::default(rt).await?; let modules = { let mut tx = t.database.begin(Identity::system()).await?; ModuleModel::new(&mut tx) .get_application_modules(ComponentId::test_user(), t.module_loader.as_ref()) .await? }; let udf_config = UdfConfig::new_for_test(&t.rt, "1000.0.0".parse()?); let mut result = t .isolate .analyze( udf_config, modules, BTreeMap::new(), DEV_INSTANCE_NAME.to_string(), ) .await??; let source_maps_path = "sourceMaps.js".parse()?; let analyzed_module = result.remove(&source_maps_path).unwrap(); assert_eq!( &Vec::from(analyzed_module.functions.clone()), &[ AnalyzedFunction::new( "throwsError".parse()?, Some(AnalyzedSourcePosition { path: "sourceMaps.js".parse()?, start_lineno: 21, start_col: analyzed_module.functions[0].pos.as_ref().unwrap().start_col, }), UdfType::Query, Some(Visibility::Public), ArgsValidator::Unvalidated, ReturnsValidator::Unvalidated, )?, AnalyzedFunction::new( "throwsErrorInDep".parse()?, Some(AnalyzedSourcePosition { path: "sourceMaps.js".parse()?, start_lineno: 27, start_col: analyzed_module.functions[1].pos.as_ref().unwrap().start_col, }), UdfType::Query, Some(Visibility::Public), ArgsValidator::Unvalidated, ReturnsValidator::Unvalidated, )?, ], ); analyzed_module.source_index.unwrap(); Ok(()) } #[convex_macro::test_runtime] async fn test_analyze_internal_function(rt: TestRuntime) -> anyhow::Result<()> { let t = UdfTest::default(rt).await?; let modules = { let mut tx = t.database.begin(Identity::system()).await?; ModuleModel::new(&mut tx) .get_application_modules(ComponentId::test_user(), t.module_loader.as_ref()) .await? }; let udf_config = UdfConfig::new_for_test(&t.rt, "1000.0.0".parse()?); let mut result = t .isolate .analyze( udf_config, modules, BTreeMap::new(), DEV_INSTANCE_NAME.to_string(), ) .await??; let internal_path = "internal.js".parse()?; let analyzed_module = result.remove(&internal_path).unwrap(); assert_eq!( &Vec::from(analyzed_module.functions.clone()), &[ AnalyzedFunction::new( "myInternalQuery".parse()?, // Don't check line numbers since those change on every `convex/server` // change. Some(AnalyzedSourcePosition { path: "internal.js".parse()?, start_lineno: 17, start_col: analyzed_module.functions[0].pos.as_ref().unwrap().start_col, }), UdfType::Query, Some(Visibility::Internal), ArgsValidator::Unvalidated, ReturnsValidator::Unvalidated, )?, AnalyzedFunction::new( "publicQuery".parse()?, // Don't check line numbers since those change on every `convex/server` // change. Some(AnalyzedSourcePosition { path: "internal.js".parse()?, start_lineno: 21, start_col: analyzed_module.functions[1].pos.as_ref().unwrap().start_col, }), UdfType::Query, Some(Visibility::Public), ArgsValidator::Unvalidated, ReturnsValidator::Unvalidated, )?, AnalyzedFunction::new( "myInternalMutation".parse()?, // Don't check line numbers since those change on every `convex/server` // change. Some(AnalyzedSourcePosition { path: "internal.js".parse()?, start_lineno: 24, start_col: analyzed_module.functions[2].pos.as_ref().unwrap().start_col, }), UdfType::Mutation, Some(Visibility::Internal), ArgsValidator::Unvalidated, ReturnsValidator::Unvalidated, )?, AnalyzedFunction::new( "publicMutation".parse()?, // Don't check line numbers since those change on every `convex/server` // change. Some(AnalyzedSourcePosition { path: "internal.js".parse()?, start_lineno: 26, start_col: analyzed_module.functions[3].pos.as_ref().unwrap().start_col, }), UdfType::Mutation, Some(Visibility::Public), ArgsValidator::Unvalidated, ReturnsValidator::Unvalidated, )?, ], ); Ok(()) } #[convex_macro::test_runtime] async fn test_analyze_developer_errors(rt: TestRuntime) -> anyhow::Result<()> { let run_test = |source: &'static str, expected_error: &'static str| { let rt = rt.clone(); async move { let module = ModuleConfig { path: "broken.js".parse()?, source: source.into(), source_map: None, environment: ModuleEnvironment::Isolate, }; let err = match UdfTest::default_with_modules(vec![module], rt.clone()).await { Ok(Err(js_error)) => js_error.to_string(), Err(e) if e.is_bad_request() => e.to_string(), _ => anyhow::bail!("No JsError raised for broken source: {}", source), }; assert!( format!("{err}").contains(expected_error), "Uhoh: {err:?} - did not contain {expected_error}" ); Ok(()) } }; // Syntax errors should be propagated back to the developer. run_test("const x = 'what", "SyntaxError").await?; // `esbuild` should catch most import errors, but we should still degrade // gracefully if we see an import error at this layer. run_test( "import { something } from 'nonexistent';", r#"Relative import path "nonexistent" not prefixed with /"#, ) .await?; run_test( "\n\nimport { something } from 'https://bad@scheme.com/module';", r#"convex:/broken.js:2:26: Unsupported scheme (https) in"#, ) .await?; run_test( "\n\nimport { something } from 'convex://cdnjs.cloudflare.com/module';", r#"convex:/broken.js:2:26: Module URL convex://cdnjs.cloudflare.com/module must not have an authority. Has cdnjs.cloudflare.com"#, ) .await?; run_test( "import { something } from './nonexistent';", "Couldn't find JavaScript module", ) .await?; // Throwing an error within a syntactically valid module is still a developer // error. The error message is a bit jank, but hopefully it's good enough // for now to point developers to their errors. // ``` // Uncaught Error: Uncaught Error: no thanks // at <anonymous> (convex:/broken.js:1:7) // at <anonymous> (convex:/_system/cli/listModules.js:14:27) // at async invokeQuery (convex:/_system/_deps/HBQGL2NV.js:774:18) // // at <anonymous> (convex:/_system/cli/listModules.js:14:27) // at async invokeQuery (convex:/_system/_deps/HBQGL2NV.js:774:18) // ``` run_test("throw new Error('no thanks');", "Uncaught Error: no thanks").await?; run_test( r##"Convex.syscall("insert", JSON.stringify({ table: "oh", value: { hello: "there" } }))"##, "Can't use database at import time", ) .await?; run_test( "async function test(){}; await test();", "Top-level awaits in source files are unsupported", ) .await?; Ok(()) } #[convex_macro::test_runtime] async fn test_analyze_imports_are_none(rt: TestRuntime) -> anyhow::Result<()> { // Tests that imported handler methods report None for // Option<AnalyzedSourcePosition>. In the future, we might want to also // report the file itself, and s let t = UdfTest::default(rt).await?; let cases = [ ( "http_all_imported_handlers.js", vec![ ("/test1", None), ("/test2", None), ("/test3", None), ("/test4", None), ], ), ("http.js", vec![("/imported", None)]), ( "http_no_imports.js", vec![( "/test", Some(AnalyzedSourcePosition { path: "http_no_imports.js".parse()?, start_lineno: 11, start_col: 1, }), )], ), ]; for (case, expected) in cases { // Construct the http.js module for analysis let http_path = "http.js".parse()?; let mut modules = { let mut tx = t.database.begin(Identity::system()).await?; ModuleModel::new(&mut tx) .get_application_modules(ComponentId::test_user(), t.module_loader.as_ref()) .await? }; // Reinsert the case as http.js, replacing the old http.js, so that the // http_analyze codepath is used on this file. let case_canon_path = case.parse()?; let module_config = modules .remove(&case_canon_path) .expect("Could not find case in list of modules"); // For any file that is not http.js/ts, we need to remove the original http.js // and reinsert the path with http if !case_canon_path.is_http() { modules .remove(&http_path) .expect("Could not find original http.js"); // Reinsert with and without http let with_http = case_canon_path.with_http(); modules.insert(with_http, module_config.clone()); // Reinsertion } // Reinsert original path so analysis doesn't complain it's missing modules.insert(case_canon_path.clone(), module_config); // Run analysis let udf_config = UdfConfig::new_for_test(&t.rt, "1000.0.0".parse()?); let mut analyze_result = t .isolate .analyze( udf_config, modules, BTreeMap::new(), DEV_INSTANCE_NAME.to_string(), ) .await? .expect("analyze failed"); let with_http = case_canon_path.with_http(); let module = analyze_result .remove(&with_http) .expect("could not find result for path with http"); // Verify routes and lineno match let routes = module .http_routes .expect("http_routes in source_mapped was None"); let route_map: BTreeMap<_, _> = routes .into_iter() .map(|f| (f.route.path.clone(), f.pos)) .collect(); for (route, pos) in expected.into_iter() { assert_eq!( pos.as_ref(), route_map .get(route) .expect("could not find route in route_map") .as_ref() ); } } Ok(()) }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/get-convex/convex-backend'

If you have feedback or need assistance with the MCP directory API, please join our Discord server