use std::future::Future;
use std::pin::Pin;
use wasmtime::{FuncType, HeapType, Linker, RefType, Val, ValType};
use crate::runtime::StoreData;
use crate::runtime::call_log::{Payload, Side, record_payload};
use crate::runtime::fuel;
use crate::runtime::host::{intrinsic_string_type, read_string_arg, register_host_fn_async};
use crate::stdlib::shared::check_security_call;
use crate::{PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};
pub const MCP_MODULE_NAME: &str = "submilli:mcp";
pub const MCP_MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
mod response;
pub use response::McpResponse;
#[derive(Debug)]
pub enum McpCallError {
ResponseTooLarge,
Internal { message: &'static str },
AuthExpired,
Mcp { message: String },
Upstream { status: u16, body: String },
Transport(String),
Local(String),
}
impl McpCallError {
fn record(&self) -> serde_json::Value {
let (kind, message, status) = match self {
McpCallError::ResponseTooLarge => ("response-too-large", String::new(), None),
McpCallError::Internal { message } => ("internal", (*message).to_owned(), None),
McpCallError::AuthExpired => ("auth-expired", String::new(), None),
McpCallError::Mcp { message } => ("mcp", message.clone(), None),
McpCallError::Upstream { status, body } => ("upstream", body.clone(), Some(*status)),
McpCallError::Transport(detail) => ("transport", detail.clone(), None),
McpCallError::Local(detail) => ("local", detail.clone(), None),
};
serde_json::json!({ "kind": kind, "message": message, "status": status })
}
}
pub struct McpOutcome {
pub result: Result<McpResponse, McpCallError>,
pub received_bytes: u64,
pub parsed_bytes: u64,
}
pub trait McpTransport: Send + Sync {
fn call<'a>(
&'a self,
server: &'a str,
tool: &'a str,
args_json: &'a str,
) -> Pin<Box<dyn Future<Output = McpOutcome> + Send + 'a>>;
}
fn mcp_call_func_type(linker: &Linker<StoreData>) -> wasmtime::Result<FuncType> {
let engine = linker.engine();
let string_struct = intrinsic_string_type(engine)?;
let string = ValType::Ref(RefType::new(false, HeapType::ConcreteStruct(string_struct)));
let object = crate::runtime::intrinsic_types::build_intrinsic_types(engine)?.object;
let unknown = ValType::Ref(RefType::new(true, HeapType::ConcreteStruct(object)));
Ok(FuncType::new(
engine,
[string.clone(), string.clone(), string.clone()],
[unknown],
))
}
pub fn install_mcp_async(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
let ty = mcp_call_func_type(linker)?;
register_host_fn_async(
linker,
MCP_MODULE_NAME,
crate::mangle::host(MCP_MODULE_NAME, "call"),
ty,
false,
move |caller, params, results| {
Box::pin(async move {
let params: &[Val; 3] = params.try_into().map_err(|_| {
crate::runtime::host::fatal_host_error("MCP argument count is invalid")
})?;
let result_slot = results.first_mut().ok_or_else(|| {
crate::runtime::host::fatal_host_error("MCP result slot is missing")
})?;
let server = read_string_arg(&mut *caller, ¶ms[0], "mcp.call (server)")?;
let tool = read_string_arg(&mut *caller, ¶ms[1], "mcp.call (tool)")?;
let args_json = read_string_arg(&mut *caller, ¶ms[2], "mcp.call (args)")?;
let ticket = check_security_call(
&mut *caller,
&format!("mcp.{server}"),
serde_json::json!({ "tool": tool, "transport": "streamable_http" }),
)?;
record_payload(&*caller, ticket, Side::Request, || {
request_payload(&server, &tool, &args_json)
});
let transport = caller.data().mcp_transport.clone().ok_or_else(|| {
wasmtime::Error::msg(format!(
"@mcp/{server}.{tool}: MCP transport not configured"
))
})?;
fuel::charge(&mut *caller, fuel::IO, args_json.len() as u64)?;
let outcome = transport.call(&server, &tool, &args_json).await;
record_payload(&*caller, ticket, Side::Response, || {
let payload = match &outcome.result {
Ok(response) => Payload::meta(serde_json::Value::Null).with_owned_body(
serde_json::to_vec(response.value()).unwrap_or_default(),
),
Err(error) => Payload::meta(error.record()),
};
payload.with_size(outcome.received_bytes)
});
fuel::settle(&mut *caller, fuel::IO, outcome.received_bytes)?;
fuel::settle(&mut *caller, fuel::PARSE, outcome.parsed_bytes)?;
fuel::settle_result(caller, |caller| {
let result = (|| {
let response = outcome
.result
.map_err(|error| mcp_error_to_throw(&server, &tool, error))?;
let value = allocate_response(caller, &response)?;
*result_slot = value;
Ok(())
})();
result.map_err(|error| crate::runtime::host::throw_host_error(caller, error))
})
})
},
)
}
fn request_payload<'a>(server: &str, tool: &str, args_json: &'a str) -> Payload<'a> {
Payload::meta(serde_json::json!({ "server": server, "tool": tool }))
.with_body(args_json.as_bytes())
}
pub fn request_digest(server: &str, tool: &str, args_json: &str) -> String {
request_payload(server, tool, args_json).digest()
}
fn allocate_response(
caller: &mut wasmtime::Caller<'_, StoreData>,
response: &McpResponse,
) -> wasmtime::Result<Val> {
fuel::settle_result(caller, |caller| {
fuel::settle(&mut *caller, fuel::ELEM, response.visited_nodes())?;
let allocator = crate::runtime::json::JsonUnknownAllocator::new(caller)?;
let value = crate::runtime::json::JsonValue::from(response.value());
allocator.allocate(caller, &value)
})
}
pub fn mcp_call_package_declaration() -> PackageDeclaration {
let mut defs = PackageDeclaration::with_package(MCP_MODULE_NAME);
defs.values.insert(
"call".to_string(),
ValueSymbol {
name: "call".to_string(),
mangled_name: crate::mangle::host(MCP_MODULE_NAME, "call"),
declaration_span: Span::at(crate::FileId::MCP),
kind: ValueKind::Function {
generics: Vec::new(),
params: vec![
Param::new("server", Type::String),
Param::new("tool", Type::String),
Param::new("args", Type::String),
],
ret: Type::Unknown,
type_predicate: None,
doc: None,
},
},
);
defs
}
fn mcp_error_to_throw(server: &str, tool: &str, err: McpCallError) -> wasmtime::Error {
let message = match err {
McpCallError::Internal { message, .. } => {
return crate::runtime::host::fatal_host_error(message);
}
McpCallError::ResponseTooLarge => {
return crate::runtime::host::range_error(format!(
"@mcp/{server}.{tool}: response exceeds the size, node or depth limit; request a smaller result"
));
}
McpCallError::AuthExpired => format!(
"McpAuthExpiredError: @mcp/{server}.{tool}: OAuth authentication failed; \
retry later or authenticate the MCP server again"
),
McpCallError::Mcp { message } => {
format!("@mcp/{server}.{tool}: {message}")
}
McpCallError::Upstream { status, body } => {
format!("@mcp/{server}.{tool}: server returned HTTP {status}: {body}")
}
McpCallError::Transport(detail) | McpCallError::Local(detail) => {
format!("@mcp/{server}.{tool}: transport error: {detail}")
}
};
wasmtime::Error::msg(message)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use wasmtime::{Linker, Module};
use super::{McpOutcome, McpResponse, McpTransport};
use crate::runtime::security::{CheckOutcome, SecurityCheck};
use crate::runtime::{
RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
install_tenant_limits,
};
use crate::{
ObjectField, PackageDeclaration, Param, Shape, Span, Type, ValueKind, ValueSymbol,
compile_script, mangle,
};
fn test_mcp_package() -> PackageDeclaration {
let pkg = "@mcp/test";
let mut defs = PackageDeclaration::with_package(pkg);
defs.mcp_server = Some("test".to_string());
let mut arg_fields = BTreeMap::new();
arg_fields.insert("msg".to_string(), ObjectField::required(Type::String));
let arg_obj = Type::Object {
index: None,
fields: arg_fields,
};
let mut ret_fields = BTreeMap::new();
ret_fields.insert("reply".to_string(), ObjectField::required(Type::String));
let ret_obj = Type::Object {
index: None,
fields: ret_fields,
};
let mut opt_fields = BTreeMap::new();
opt_fields.insert("user".to_string(), ObjectField::optional(Type::String));
let opt_arg_obj = Type::Object {
index: None,
fields: opt_fields,
};
defs.shapes.push(Shape::from_type(&arg_obj).unwrap());
defs.shapes.push(Shape::from_type(&ret_obj).unwrap());
defs.shapes.push(Shape::from_type(&opt_arg_obj).unwrap());
let mut insert_fn = |name: &str, params: Vec<Param>, ret: Type| {
defs.values.insert(
name.to_string(),
ValueSymbol {
name: name.to_string(),
mangled_name: mangle::host(pkg, name),
declaration_span: Span::at(crate::FileId::MCP),
kind: ValueKind::Function {
generics: Vec::new(),
params,
ret,
type_predicate: None,
doc: None,
},
},
);
};
insert_fn("echo", vec![Param::new("args", arg_obj)], ret_obj);
insert_fn("raw", Vec::new(), Type::Unknown);
insert_fn(
"getTeams",
vec![Param::with_default(
"args",
opt_arg_obj,
crate::DefaultValue::EmptyObject,
)],
Type::Unknown,
);
defs
}
struct MockTransport {
result: serde_json::Value,
calls: Arc<Mutex<Vec<(String, String, String)>>>,
}
impl McpTransport for MockTransport {
fn call<'a>(
&'a self,
server: &'a str,
tool: &'a str,
args_json: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = McpOutcome> + Send + 'a>> {
self.calls.lock().unwrap().push((
server.to_string(),
tool.to_string(),
args_json.to_string(),
));
let mut result = self.result.clone();
Box::pin(async move {
McpOutcome {
result: McpResponse::take(&mut result),
received_bytes: 0,
parsed_bytes: 0,
}
})
}
}
struct DenyAll;
impl SecurityCheck for DenyAll {
fn check(&self, _: &str, capability: &str, _: &serde_json::Value) -> CheckOutcome {
CheckOutcome::Deny {
rule: None,
reason: format!("denied {capability}"),
}
}
}
async fn run(
source: &str,
result: serde_json::Value,
security: Option<Arc<dyn SecurityCheck>>,
) -> (
wasmtime::Result<Option<String>>,
Arc<Mutex<Vec<(String, String, String)>>>,
) {
let pkg = test_mcp_package();
let compiled =
compile_script(source, "<test>", crate::FileId(0), &[], &[&pkg]).expect("compiles");
let cfg = RuntimeConfig::default();
let engine = cfg.engine().unwrap();
let calls = Arc::new(Mutex::new(Vec::new()));
let mut data = StoreData::with_vfs(Vfs::tempdir().unwrap());
data.install_type_info(compiled.type_info.clone());
data.mcp_transport = Some(Arc::new(MockTransport {
result,
calls: calls.clone(),
}));
if let Some(check) = security {
data.security_check = check;
}
let mut store = cfg.store_async(&engine, data).unwrap();
install_tenant_limits(&mut store);
let module = Module::new(&engine, &compiled.wasm).unwrap();
let mut linker = Linker::<StoreData>::new(&engine);
install_runtime_async(&mut linker, &mut store)
.await
.unwrap();
let inst = linker.instantiate_async(&mut store, &module).await.unwrap();
let out = dispatch_main_async(&mut store, &inst).await;
(out, calls)
}
struct FailingTransport {
internal: bool,
}
impl McpTransport for FailingTransport {
fn call<'a>(
&'a self,
_: &'a str,
_: &'a str,
_: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = McpOutcome> + Send + 'a>> {
Box::pin(async move {
McpOutcome {
result: Err(if self.internal {
super::McpCallError::Internal {
message: "MCP HTTP client initialization failed",
}
} else {
super::McpCallError::Transport("remote connection failed".into())
}),
received_bytes: 0,
parsed_bytes: 0,
}
})
}
}
#[tokio::test]
async fn internal_client_failure_bypasses_guest_catch_and_keeps_runtime_usable() {
let package = test_mcp_package();
let source = r#"import test from "@mcp/test"; function main(): number { try { test.raw(); } catch (e) { return 7; } return 9; }"#;
let compiled =
compile_script(source, "<test>", crate::FileId(0), &[], &[&package]).unwrap();
let healthy = compile_script(
"function main(): number { return 42; }",
"<healthy>",
crate::FileId(0),
&[],
&[],
)
.unwrap();
let cfg = RuntimeConfig::default();
let engine = cfg.engine().unwrap();
let mut data = StoreData::with_vfs(Vfs::none());
data.install_type_info(compiled.type_info.clone());
data.mcp_transport = Some(Arc::new(FailingTransport { internal: true }));
let mut store = cfg.store_async(&engine, data).unwrap();
install_tenant_limits(&mut store);
let mut linker = Linker::<StoreData>::new(&engine);
install_runtime_async(&mut linker, &mut store)
.await
.unwrap();
let module = Module::new(&engine, &compiled.wasm).unwrap();
let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
let error = dispatch_main_async(&mut store, &instance)
.await
.unwrap_err();
assert!(
error.is::<crate::runtime::host::FatalHostError>(),
"{error:?}"
);
store.data_mut().blocking_work.finish().await.unwrap();
store.data_mut().mcp_transport = Some(Arc::new(FailingTransport { internal: false }));
assert_eq!(
dispatch_main_async(&mut store, &instance).await.unwrap(),
Some("7".into())
);
store
.data_mut()
.install_type_info(healthy.type_info.clone());
let module = Module::new(&engine, &healthy.wasm).unwrap();
let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
assert_eq!(
dispatch_main_async(&mut store, &instance).await.unwrap(),
Some("42".into())
);
}
#[tokio::test]
async fn completed_response_is_fully_allocated_before_exhausted_fuel_stops_execution() {
use wasmtime::{Func, FuncType, Val, ValType};
let config = RuntimeConfig::default();
let engine = config.engine().unwrap();
let mut store = config
.store_async(&engine, StoreData::with_vfs(Vfs::none()))
.unwrap();
let mut linker = Linker::new(&engine);
install_runtime_async(&mut linker, &mut store)
.await
.unwrap();
let complete = Func::new(
&mut store,
FuncType::new(&engine, [ValType::I32], [ValType::I32, ValType::F64]),
|mut caller, params, results| {
let count = params[0].i32().unwrap();
let mut value = serde_json::json!((0..count).collect::<Vec<_>>());
let response = McpResponse::take(&mut value).unwrap();
let value = super::allocate_response(&mut caller, &response)?;
let storage =
crate::runtime::array_storage::ArrayStorage::read(&mut caller, &value)?;
results[0] = Val::I32(storage.len as i32);
let last = storage.get(&mut caller, (count - 1) as u32)?;
let Val::AnyRef(Some(last)) = last else {
panic!("missing last value")
};
results[1] = last.unwrap_struct(&mut caller)?.field(&mut caller, 1)?;
Ok(())
},
);
let mut result = [Val::I32(0), Val::F64(0)];
for count in [128, 256] {
store.set_fuel(100_000).unwrap();
let before = store.data().host_fuel;
complete
.call_async(&mut store, &[Val::I32(count)], &mut result)
.await
.unwrap();
let actual = store.data().host_fuel - before;
assert_eq!(
actual,
2 * crate::runtime::fuel::ELEM.cost(count as u64 + 1)
);
assert_eq!(result[0].i32(), Some(count));
}
store.set_fuel(1).unwrap();
complete
.call_async(&mut store, &[Val::I32(256)], &mut result)
.await
.unwrap();
assert_eq!(result[0].i32(), Some(256));
assert_eq!(result[1].f64(), Some(255.0));
assert_eq!(store.get_fuel().unwrap(), 0);
assert!(!store.data().settling_host_result);
assert!(crate::runtime::fuel::charge_call(&mut store).is_err());
}
#[tokio::test]
async fn typed_tool_round_trips_and_serializes_args() {
let (out, calls) = run(
r#"import test from "@mcp/test";
function main(): void {
const r = test.echo({ msg: "hi" });
assert(r.reply === "yo", "reply");
}"#,
serde_json::json!({ "reply": "yo" }),
None,
)
.await;
out.expect("typed MCP call must succeed");
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "test");
assert_eq!(calls[0].1, "echo");
assert!(
calls[0].2.contains("\"msg\":\"hi\""),
"args serialized to JSON: {}",
calls[0].2
);
}
#[tokio::test]
async fn all_optional_tool_callable_with_zero_args_serializes_empty_object() {
let (out, calls) = run(
r#"import test from "@mcp/test";
function main(): string {
return test.getTeams() as string;
}"#,
serde_json::json!("[]"),
None,
)
.await;
out.expect("zero-arg MCP call must succeed");
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].1, "getTeams");
assert_eq!(calls[0].2, "{}", "empty optional args serialize to {{}}");
}
#[tokio::test]
async fn named_import_also_routes_to_mcp_call() {
let (out, calls) = run(
r#"import { echo } from "@mcp/test";
function main(): void {
const r = echo({ msg: "hi" });
assert(r.reply === "yo", "reply");
}"#,
serde_json::json!({ "reply": "yo" }),
None,
)
.await;
out.expect("named-import MCP call must succeed");
assert_eq!(calls.lock().unwrap()[0].1, "echo");
}
#[tokio::test]
async fn untyped_tool_returns_unknown_json_value() {
let (out, _) = run(
r#"import test from "@mcp/test";
interface RawResult { k: number; }
function main(): void {
const r = test.raw() as RawResult;
assert(r.k === 1, "raw");
}"#,
serde_json::json!({ "k": 1 }),
None,
)
.await;
out.expect("untyped MCP call must succeed");
}
#[tokio::test]
async fn zero_arg_tool_sends_empty_object() {
let (_out, calls) = run(
r#"import test from "@mcp/test";
function main(): void { test.raw(); }"#,
serde_json::json!({ "k": 1 }),
None,
)
.await;
assert_eq!(calls.lock().unwrap()[0].2, "{}");
}
#[tokio::test]
async fn denied_call_throws_before_dispatch() {
let (out, calls) = run(
r#"import test from "@mcp/test";
function main(): void { test.echo({ msg: "hi" }); }"#,
serde_json::json!({ "reply": "yo" }),
Some(Arc::new(DenyAll)),
)
.await;
let err = out.expect_err("a denied capability must throw");
assert!(
err.to_string().contains("permission denied"),
"unexpected error: {err}"
);
assert!(
calls.lock().unwrap().is_empty(),
"transport must not be reached when denied"
);
}
}