1use std::future::Future;
16use std::pin::Pin;
17
18use wasmtime::{FuncType, HeapType, Linker, RefType, Val, ValType};
19
20use crate::runtime::StoreData;
21use crate::runtime::call_log::{Payload, Side, record_payload};
22use crate::runtime::fuel;
23use crate::runtime::host::{intrinsic_string_type, read_string_arg, register_host_fn_async};
24use crate::stdlib::shared::check_security_call;
25use crate::{PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};
26
27pub const MCP_MODULE_NAME: &str = "submilli:mcp";
29pub const MCP_MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
30
31mod response;
32pub use response::McpResponse;
33
34#[derive(Debug)]
37pub enum McpCallError {
38 ResponseTooLarge,
40 Internal { message: &'static str },
42 AuthExpired,
45 Mcp { message: String },
47 Upstream { status: u16, body: String },
49 Transport(String),
51 Local(String),
54}
55
56impl McpCallError {
57 fn record(&self) -> serde_json::Value {
60 let (kind, message, status) = match self {
61 McpCallError::ResponseTooLarge => ("response-too-large", String::new(), None),
62 McpCallError::Internal { message } => ("internal", (*message).to_owned(), None),
63 McpCallError::AuthExpired => ("auth-expired", String::new(), None),
64 McpCallError::Mcp { message } => ("mcp", message.clone(), None),
65 McpCallError::Upstream { status, body } => ("upstream", body.clone(), Some(*status)),
66 McpCallError::Transport(detail) => ("transport", detail.clone(), None),
67 McpCallError::Local(detail) => ("local", detail.clone(), None),
68 };
69 serde_json::json!({ "kind": kind, "message": message, "status": status })
70 }
71}
72
73pub struct McpOutcome {
75 pub result: Result<McpResponse, McpCallError>,
76 pub received_bytes: u64,
77 pub parsed_bytes: u64,
78}
79
80pub trait McpTransport: Send + Sync {
89 fn call<'a>(
90 &'a self,
91 server: &'a str,
92 tool: &'a str,
93 args_json: &'a str,
94 ) -> Pin<Box<dyn Future<Output = McpOutcome> + Send + 'a>>;
95}
96
97fn mcp_call_func_type(linker: &Linker<StoreData>) -> wasmtime::Result<FuncType> {
98 let engine = linker.engine();
99 let string_struct = intrinsic_string_type(engine)?;
100 let string = ValType::Ref(RefType::new(false, HeapType::ConcreteStruct(string_struct)));
101 let object = crate::runtime::intrinsic_types::build_intrinsic_types(engine)?.object;
102 let unknown = ValType::Ref(RefType::new(true, HeapType::ConcreteStruct(object)));
103 Ok(FuncType::new(
104 engine,
105 [string.clone(), string.clone(), string.clone()],
106 [unknown],
107 ))
108}
109
110pub fn install_mcp_async(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
114 let ty = mcp_call_func_type(linker)?;
115 register_host_fn_async(
116 linker,
117 MCP_MODULE_NAME,
118 crate::mangle::host(MCP_MODULE_NAME, "call"),
119 ty,
120 false,
121 move |caller, params, results| {
122 Box::pin(async move {
123 let params: &[Val; 3] = params.try_into().map_err(|_| {
124 crate::runtime::host::fatal_host_error("MCP argument count is invalid")
125 })?;
126 let result_slot = results.first_mut().ok_or_else(|| {
127 crate::runtime::host::fatal_host_error("MCP result slot is missing")
128 })?;
129 let server = read_string_arg(&mut *caller, ¶ms[0], "mcp.call (server)")?;
130 let tool = read_string_arg(&mut *caller, ¶ms[1], "mcp.call (tool)")?;
131 let args_json = read_string_arg(&mut *caller, ¶ms[2], "mcp.call (args)")?;
132
133 let ticket = check_security_call(
137 &mut *caller,
138 &format!("mcp.{server}"),
139 serde_json::json!({ "tool": tool, "transport": "streamable_http" }),
140 )?;
141 record_payload(&*caller, ticket, Side::Request, || {
142 request_payload(&server, &tool, &args_json)
143 });
144
145 let transport = caller.data().mcp_transport.clone().ok_or_else(|| {
146 wasmtime::Error::msg(format!(
147 "@mcp/{server}.{tool}: MCP transport not configured"
148 ))
149 })?;
150
151 fuel::charge(&mut *caller, fuel::IO, args_json.len() as u64)?;
152 let outcome = transport.call(&server, &tool, &args_json).await;
153 record_payload(&*caller, ticket, Side::Response, || {
154 let payload = match &outcome.result {
155 Ok(response) => Payload::meta(serde_json::Value::Null).with_owned_body(
156 serde_json::to_vec(response.value()).unwrap_or_default(),
157 ),
158 Err(error) => Payload::meta(error.record()),
159 };
160 payload.with_size(outcome.received_bytes)
161 });
162 fuel::settle(&mut *caller, fuel::IO, outcome.received_bytes)?;
163 fuel::settle(&mut *caller, fuel::PARSE, outcome.parsed_bytes)?;
164 fuel::settle_result(caller, |caller| {
165 let result = (|| {
166 let response = outcome
167 .result
168 .map_err(|error| mcp_error_to_throw(&server, &tool, error))?;
169 let value = allocate_response(caller, &response)?;
170 *result_slot = value;
171 Ok(())
172 })();
173 result.map_err(|error| crate::runtime::host::throw_host_error(caller, error))
174 })
175 })
176 },
177 )
178}
179
180fn request_payload<'a>(server: &str, tool: &str, args_json: &'a str) -> Payload<'a> {
182 Payload::meta(serde_json::json!({ "server": server, "tool": tool }))
183 .with_body(args_json.as_bytes())
184}
185
186pub fn request_digest(server: &str, tool: &str, args_json: &str) -> String {
189 request_payload(server, tool, args_json).digest()
190}
191
192pub(crate) fn allocate_response(
193 caller: &mut wasmtime::Caller<'_, StoreData>,
194 response: &McpResponse,
195) -> wasmtime::Result<Val> {
196 fuel::settle_result(caller, |caller| {
197 fuel::settle(&mut *caller, fuel::ELEM, response.visited_nodes())?;
198 let allocator = crate::runtime::json::JsonUnknownAllocator::new(caller)?;
199 let value = crate::runtime::json::JsonValue::from(response.value());
200 allocator.allocate(caller, &value)
201 })
202}
203
204pub fn mcp_call_package_declaration() -> PackageDeclaration {
208 let mut defs = PackageDeclaration::with_package(MCP_MODULE_NAME);
209 defs.values.insert(
210 "call".to_string(),
211 ValueSymbol {
212 name: "call".to_string(),
213 mangled_name: crate::mangle::host(MCP_MODULE_NAME, "call"),
214 declaration_span: Span::at(crate::FileId::MCP),
215 kind: ValueKind::Function {
216 generics: Vec::new(),
217 params: vec![
218 Param::new("server", Type::String),
219 Param::new("tool", Type::String),
220 Param::new("args", Type::String),
221 ],
222 ret: Type::Unknown,
223 type_predicate: None,
224 doc: None,
225 },
226 },
227 );
228 defs
229}
230
231fn mcp_error_to_throw(server: &str, tool: &str, err: McpCallError) -> wasmtime::Error {
235 let message = match err {
236 McpCallError::Internal { message, .. } => {
237 return crate::runtime::host::fatal_host_error(message);
238 }
239 McpCallError::ResponseTooLarge => {
240 return crate::runtime::host::range_error(format!(
241 "@mcp/{server}.{tool}: response exceeds the size, node or depth limit; request a smaller result"
242 ));
243 }
244 McpCallError::AuthExpired => format!(
245 "McpAuthExpiredError: @mcp/{server}.{tool}: OAuth authentication failed; \
246 retry later or authenticate the MCP server again"
247 ),
248 McpCallError::Mcp { message } => {
249 format!("@mcp/{server}.{tool}: {message}")
250 }
251 McpCallError::Upstream { status, body } => {
252 format!("@mcp/{server}.{tool}: server returned HTTP {status}: {body}")
253 }
254 McpCallError::Transport(detail) | McpCallError::Local(detail) => {
255 format!("@mcp/{server}.{tool}: transport error: {detail}")
256 }
257 };
258 wasmtime::Error::msg(message)
259}
260
261#[cfg(test)]
262mod tests {
263 use std::collections::BTreeMap;
264
265 use std::sync::{Arc, Mutex};
266
267 use wasmtime::{Linker, Module};
268
269 use super::{McpOutcome, McpResponse, McpTransport};
270 use crate::runtime::security::{CheckOutcome, SecurityCheck};
271 use crate::runtime::{
272 RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
273 install_tenant_limits,
274 };
275 use crate::{
276 ObjectField, PackageDeclaration, Param, Shape, Span, Type, ValueKind, ValueSymbol,
277 compile_script, mangle,
278 };
279
280 fn test_mcp_package() -> PackageDeclaration {
285 let pkg = "@mcp/test";
286 let mut defs = PackageDeclaration::with_package(pkg);
287 defs.virtual_dispatch = Some(crate::VirtualDispatch::Mcp {
288 server: "test".to_string(),
289 });
290
291 let mut arg_fields = BTreeMap::new();
292 arg_fields.insert("msg".to_string(), ObjectField::required(Type::String));
293 let arg_obj = Type::Object {
294 index: None,
295 fields: arg_fields,
296 };
297 let mut ret_fields = BTreeMap::new();
298 ret_fields.insert("reply".to_string(), ObjectField::required(Type::String));
299 let ret_obj = Type::Object {
300 index: None,
301 fields: ret_fields,
302 };
303 let mut opt_fields = BTreeMap::new();
304 opt_fields.insert("user".to_string(), ObjectField::optional(Type::String));
305 let opt_arg_obj = Type::Object {
306 index: None,
307 fields: opt_fields,
308 };
309 defs.shapes.push(Shape::from_type(&arg_obj).unwrap());
310 defs.shapes.push(Shape::from_type(&ret_obj).unwrap());
311 defs.shapes.push(Shape::from_type(&opt_arg_obj).unwrap());
312
313 let mut insert_fn = |name: &str, params: Vec<Param>, ret: Type| {
314 defs.values.insert(
315 name.to_string(),
316 ValueSymbol {
317 name: name.to_string(),
318 mangled_name: mangle::host(pkg, name),
319 declaration_span: Span::at(crate::FileId::MCP),
320 kind: ValueKind::Function {
321 generics: Vec::new(),
322 params,
323 ret,
324 type_predicate: None,
325 doc: None,
326 },
327 },
328 );
329 };
330 insert_fn("echo", vec![Param::new("args", arg_obj)], ret_obj);
331 insert_fn("raw", Vec::new(), Type::Unknown);
332 insert_fn(
334 "getTeams",
335 vec![Param::with_default(
336 "args",
337 opt_arg_obj,
338 crate::DefaultValue::EmptyObject,
339 )],
340 Type::Unknown,
341 );
342 defs
343 }
344
345 struct MockTransport {
347 result: serde_json::Value,
348 calls: Arc<Mutex<Vec<(String, String, String)>>>,
349 }
350
351 impl McpTransport for MockTransport {
352 fn call<'a>(
353 &'a self,
354 server: &'a str,
355 tool: &'a str,
356 args_json: &'a str,
357 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = McpOutcome> + Send + 'a>> {
358 self.calls.lock().unwrap().push((
359 server.to_string(),
360 tool.to_string(),
361 args_json.to_string(),
362 ));
363 let mut result = self.result.clone();
364 Box::pin(async move {
365 McpOutcome {
366 result: McpResponse::take(&mut result),
367 received_bytes: 0,
368 parsed_bytes: 0,
369 }
370 })
371 }
372 }
373
374 struct DenyAll;
375 impl SecurityCheck for DenyAll {
376 fn check(&self, _: &str, capability: &str, _: &serde_json::Value) -> CheckOutcome {
377 CheckOutcome::Deny {
378 rule: None,
379 reason: format!("denied {capability}"),
380 }
381 }
382 }
383
384 async fn run(
385 source: &str,
386 result: serde_json::Value,
387 security: Option<Arc<dyn SecurityCheck>>,
388 ) -> (
389 wasmtime::Result<Option<String>>,
390 Arc<Mutex<Vec<(String, String, String)>>>,
391 ) {
392 let pkg = test_mcp_package();
393 let compiled =
394 compile_script(source, "<test>", crate::FileId(0), &[], &[&pkg]).expect("compiles");
395
396 let cfg = RuntimeConfig::default();
397 let engine = cfg.engine().unwrap();
398 let calls = Arc::new(Mutex::new(Vec::new()));
399 let mut data = StoreData::with_vfs(Vfs::tempdir().unwrap());
400 data.install_type_info(compiled.type_info.clone());
401 data.mcp_transport = Some(Arc::new(MockTransport {
402 result,
403 calls: calls.clone(),
404 }));
405 if let Some(check) = security {
406 data.security_check = check;
407 }
408 let mut store = cfg.store_async(&engine, data).unwrap();
409 install_tenant_limits(&mut store);
410 let module = Module::new(&engine, &compiled.wasm).unwrap();
411 let mut linker = Linker::<StoreData>::new(&engine);
412 install_runtime_async(&mut linker, &mut store)
413 .await
414 .unwrap();
415 let inst = linker.instantiate_async(&mut store, &module).await.unwrap();
416 let out = dispatch_main_async(&mut store, &inst).await;
417 (out, calls)
418 }
419
420 struct FailingTransport {
421 internal: bool,
422 }
423
424 impl McpTransport for FailingTransport {
425 fn call<'a>(
426 &'a self,
427 _: &'a str,
428 _: &'a str,
429 _: &'a str,
430 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = McpOutcome> + Send + 'a>> {
431 Box::pin(async move {
432 McpOutcome {
433 result: Err(if self.internal {
434 super::McpCallError::Internal {
435 message: "MCP HTTP client initialization failed",
436 }
437 } else {
438 super::McpCallError::Transport("remote connection failed".into())
439 }),
440 received_bytes: 0,
441 parsed_bytes: 0,
442 }
443 })
444 }
445 }
446
447 #[tokio::test]
448 async fn internal_client_failure_bypasses_guest_catch_and_keeps_runtime_usable() {
449 let package = test_mcp_package();
450 let source = r#"import test from "@mcp/test"; function main(): number { try { test.raw(); } catch (e) { return 7; } return 9; }"#;
451 let compiled =
452 compile_script(source, "<test>", crate::FileId(0), &[], &[&package]).unwrap();
453 let healthy = compile_script(
454 "function main(): number { return 42; }",
455 "<healthy>",
456 crate::FileId(0),
457 &[],
458 &[],
459 )
460 .unwrap();
461 let cfg = RuntimeConfig::default();
462 let engine = cfg.engine().unwrap();
463 let mut data = StoreData::with_vfs(Vfs::none());
464 data.install_type_info(compiled.type_info.clone());
465 data.mcp_transport = Some(Arc::new(FailingTransport { internal: true }));
466 let mut store = cfg.store_async(&engine, data).unwrap();
467 install_tenant_limits(&mut store);
468 let mut linker = Linker::<StoreData>::new(&engine);
469 install_runtime_async(&mut linker, &mut store)
470 .await
471 .unwrap();
472 let module = Module::new(&engine, &compiled.wasm).unwrap();
473 let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
474 let error = dispatch_main_async(&mut store, &instance)
475 .await
476 .unwrap_err();
477 assert!(
478 error.is::<crate::runtime::host::FatalHostError>(),
479 "{error:?}"
480 );
481 store.data_mut().blocking_work.finish().await.unwrap();
482 store.data_mut().mcp_transport = Some(Arc::new(FailingTransport { internal: false }));
483 assert_eq!(
484 dispatch_main_async(&mut store, &instance).await.unwrap(),
485 Some("7".into())
486 );
487 store
488 .data_mut()
489 .install_type_info(healthy.type_info.clone());
490 let module = Module::new(&engine, &healthy.wasm).unwrap();
491 let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
492 assert_eq!(
493 dispatch_main_async(&mut store, &instance).await.unwrap(),
494 Some("42".into())
495 );
496 }
497
498 #[tokio::test]
499 async fn completed_response_is_fully_allocated_before_exhausted_fuel_stops_execution() {
500 use wasmtime::{Func, FuncType, Val, ValType};
501 let config = RuntimeConfig::default();
502 let engine = config.engine().unwrap();
503 let mut store = config
504 .store_async(&engine, StoreData::with_vfs(Vfs::none()))
505 .unwrap();
506 let mut linker = Linker::new(&engine);
507 install_runtime_async(&mut linker, &mut store)
508 .await
509 .unwrap();
510 let complete = Func::new(
511 &mut store,
512 FuncType::new(&engine, [ValType::I32], [ValType::I32, ValType::F64]),
513 |mut caller, params, results| {
514 let count = params[0].i32().unwrap();
515 let mut value = serde_json::json!((0..count).collect::<Vec<_>>());
516 let response = McpResponse::take(&mut value).unwrap();
517 let value = super::allocate_response(&mut caller, &response)?;
518 let storage =
519 crate::runtime::array_storage::ArrayStorage::read(&mut caller, &value)?;
520 results[0] = Val::I32(storage.len as i32);
521 let last = storage.get(&mut caller, (count - 1) as u32)?;
522 let Val::AnyRef(Some(last)) = last else {
523 panic!("missing last value")
524 };
525 results[1] = last.unwrap_struct(&mut caller)?.field(&mut caller, 1)?;
526 Ok(())
527 },
528 );
529 let mut result = [Val::I32(0), Val::F64(0)];
530 for count in [128, 256] {
531 store.set_fuel(100_000).unwrap();
532 let before = store.data().host_fuel;
533 complete
534 .call_async(&mut store, &[Val::I32(count)], &mut result)
535 .await
536 .unwrap();
537 let actual = store.data().host_fuel - before;
538 assert_eq!(
539 actual,
540 2 * crate::runtime::fuel::ELEM.cost(count as u64 + 1)
541 );
542 assert_eq!(result[0].i32(), Some(count));
544 }
545 store.set_fuel(1).unwrap();
546 complete
547 .call_async(&mut store, &[Val::I32(256)], &mut result)
548 .await
549 .unwrap();
550 assert_eq!(result[0].i32(), Some(256));
551 assert_eq!(result[1].f64(), Some(255.0));
552 assert_eq!(store.get_fuel().unwrap(), 0);
553 assert!(!store.data().settling_host_result);
554 assert!(crate::runtime::fuel::charge_call(&mut store).is_err());
555 }
556
557 #[tokio::test]
558 async fn typed_tool_round_trips_and_serializes_args() {
559 let (out, calls) = run(
560 r#"import test from "@mcp/test";
561 function main(): void {
562 const r = test.echo({ msg: "hi" });
563 assert(r.reply === "yo", "reply");
564 }"#,
565 serde_json::json!({ "reply": "yo" }),
566 None,
567 )
568 .await;
569 out.expect("typed MCP call must succeed");
570 let calls = calls.lock().unwrap();
571 assert_eq!(calls.len(), 1);
572 assert_eq!(calls[0].0, "test");
573 assert_eq!(calls[0].1, "echo");
574 assert!(
575 calls[0].2.contains("\"msg\":\"hi\""),
576 "args serialized to JSON: {}",
577 calls[0].2
578 );
579 }
580
581 #[tokio::test]
582 async fn all_optional_tool_callable_with_zero_args_serializes_empty_object() {
583 let (out, calls) = run(
586 r#"import test from "@mcp/test";
587 function main(): string {
588 return test.getTeams() as string;
589 }"#,
590 serde_json::json!("[]"),
591 None,
592 )
593 .await;
594 out.expect("zero-arg MCP call must succeed");
595 let calls = calls.lock().unwrap();
596 assert_eq!(calls.len(), 1);
597 assert_eq!(calls[0].1, "getTeams");
598 assert_eq!(calls[0].2, "{}", "empty optional args serialize to {{}}");
599 }
600
601 #[tokio::test]
602 async fn named_import_also_routes_to_mcp_call() {
603 let (out, calls) = run(
607 r#"import { echo } from "@mcp/test";
608 function main(): void {
609 const r = echo({ msg: "hi" });
610 assert(r.reply === "yo", "reply");
611 }"#,
612 serde_json::json!({ "reply": "yo" }),
613 None,
614 )
615 .await;
616 out.expect("named-import MCP call must succeed");
617 assert_eq!(calls.lock().unwrap()[0].1, "echo");
618 }
619
620 #[tokio::test]
621 async fn untyped_tool_returns_unknown_json_value() {
622 let (out, _) = run(
623 r#"import test from "@mcp/test";
624 interface RawResult { k: number; }
625 function main(): void {
626 const r = test.raw() as RawResult;
627 assert(r.k === 1, "raw");
628 }"#,
629 serde_json::json!({ "k": 1 }),
630 None,
631 )
632 .await;
633 out.expect("untyped MCP call must succeed");
634 }
635
636 #[tokio::test]
637 async fn zero_arg_tool_sends_empty_object() {
638 let (_out, calls) = run(
639 r#"import test from "@mcp/test";
640 function main(): void { test.raw(); }"#,
641 serde_json::json!({ "k": 1 }),
642 None,
643 )
644 .await;
645 assert_eq!(calls.lock().unwrap()[0].2, "{}");
646 }
647
648 #[tokio::test]
649 async fn denied_call_throws_before_dispatch() {
650 let (out, calls) = run(
651 r#"import test from "@mcp/test";
652 function main(): void { test.echo({ msg: "hi" }); }"#,
653 serde_json::json!({ "reply": "yo" }),
654 Some(Arc::new(DenyAll)),
655 )
656 .await;
657 let err = out.expect_err("a denied capability must throw");
658 assert!(
659 err.to_string().contains("permission denied"),
660 "unexpected error: {err}"
661 );
662 assert!(
663 calls.lock().unwrap().is_empty(),
664 "transport must not be reached when denied"
665 );
666 }
667}