1use std::future::Future;
19use std::pin::Pin;
20use std::time::Instant;
21
22use serde::{Deserialize, Serialize};
23use wasmtime::{FuncType, HeapType, Linker, RefType, Val, ValType};
24
25use crate::runtime::StoreData;
26use crate::runtime::call_log::{Payload, Side, record_payload};
27use crate::runtime::fuel;
28use crate::runtime::host::{intrinsic_string_type, read_string_arg, register_host_fn_async};
29use crate::runtime::mcp::McpResponse;
30use crate::stdlib::shared::{audit_denial_in, check_security_call, running_package};
31use crate::{PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};
32
33pub const TOOLS_MODULE_NAME: &str = "submilli:tools";
35pub const TOOLS_PACKAGE: &str = "@tools";
37pub const TOOLS_CAPABILITY: &str = "tools.call";
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub enum ToolCallError {
44 Failed { message: String },
46 Denied { message: String },
49 TimedOut,
51 QuotaExceeded { message: String },
54 Cancelled,
56}
57
58impl ToolCallError {
59 fn record(&self) -> serde_json::Value {
61 let (kind, message) = match self {
62 ToolCallError::Failed { message } => ("failed", message.as_str()),
63 ToolCallError::Denied { message } => ("denied", message.as_str()),
64 ToolCallError::TimedOut => ("timeout", ""),
65 ToolCallError::QuotaExceeded { message } => ("quota", message.as_str()),
66 ToolCallError::Cancelled => ("cancelled", ""),
67 };
68 serde_json::json!({ "kind": kind, "message": message })
69 }
70}
71
72pub trait ToolProvider: Send + Sync {
80 fn call<'a>(
81 &'a self,
82 tool: &'a str,
83 args_json: &'a str,
84 ) -> Pin<Box<dyn Future<Output = Result<McpResponse, ToolCallError>> + Send + 'a>>;
85}
86
87fn tools_call_func_type(linker: &Linker<StoreData>) -> wasmtime::Result<FuncType> {
88 let engine = linker.engine();
89 let string_struct = intrinsic_string_type(engine)?;
90 let string = ValType::Ref(RefType::new(false, HeapType::ConcreteStruct(string_struct)));
91 let object = crate::runtime::intrinsic_types::build_intrinsic_types(engine)?.object;
92 let unknown = ValType::Ref(RefType::new(true, HeapType::ConcreteStruct(object)));
93 Ok(FuncType::new(engine, [string.clone(), string], [unknown]))
94}
95
96pub fn install_tools_async(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
100 let ty = tools_call_func_type(linker)?;
101 register_host_fn_async(
102 linker,
103 TOOLS_MODULE_NAME,
104 crate::mangle::host(TOOLS_MODULE_NAME, "call"),
105 ty,
106 false,
107 move |caller, params, results| {
108 Box::pin(async move {
109 let params: &[Val; 2] = params.try_into().map_err(|_| {
110 crate::runtime::host::fatal_host_error("tools argument count is invalid")
111 })?;
112 let result_slot = results.first_mut().ok_or_else(|| {
113 crate::runtime::host::fatal_host_error("tools result slot is missing")
114 })?;
115 let tool = read_string_arg(&mut *caller, ¶ms[0], "tools.call (tool)")?;
116 let args_json = read_string_arg(&mut *caller, ¶ms[1], "tools.call (args)")?;
117
118 let ticket = check_security_call(
121 &mut *caller,
122 TOOLS_CAPABILITY,
123 serde_json::json!({ "tool": tool }),
124 )?;
125 record_payload(&*caller, ticket, Side::Request, || {
126 Payload::meta(serde_json::json!({ "tool": tool }))
127 .with_body(args_json.as_bytes())
128 });
129 let provider = caller.data().tool_provider.clone().ok_or_else(|| {
132 wasmtime::Error::msg(format!(
133 "{TOOLS_PACKAGE}.{tool}: this session offers no application tools"
134 ))
135 })?;
136 let principal = running_package(&*caller)
137 .map_err(|error| error.into_denial(TOOLS_CAPABILITY))?;
138
139 fuel::charge(&mut *caller, fuel::IO, args_json.len() as u64)?;
140 let waited = Instant::now();
141 let outcome = provider.call(&tool, &args_json).await;
142 let data = caller.data_mut();
143 data.clock_paused = data.clock_paused.saturating_add(waited.elapsed());
144
145 if let Err(ToolCallError::Denied { .. }) = &outcome {
148 audit_denial_in(
149 &*caller,
150 ticket,
151 &principal,
152 TOOLS_CAPABILITY,
153 &serde_json::json!({ "tool": tool }),
154 crate::runtime::host::DenialSource::Harness.as_str(),
155 "the application refused the call",
156 );
157 }
158 record_payload(&*caller, ticket, Side::Response, || match &outcome {
159 Ok(response) => Payload::meta(serde_json::Value::Null)
160 .with_owned_body(serde_json::to_vec(response.value()).unwrap_or_default()),
161 Err(error) => Payload::meta(error.record()),
162 });
163 fuel::settle_result(caller, |caller| {
164 let result = (|| {
165 let response = outcome
166 .map_err(|error| tool_error_to_throw(&principal, &tool, error))?;
167 *result_slot = super::mcp::allocate_response(caller, &response)?;
168 Ok(())
169 })();
170 result.map_err(|error| crate::runtime::host::throw_host_error(caller, error))
171 })
172 })
173 },
174 )
175}
176
177fn tool_error_to_throw(principal: &str, tool: &str, error: ToolCallError) -> wasmtime::Error {
178 match error {
179 ToolCallError::Failed { message } => {
180 wasmtime::Error::msg(format!("{TOOLS_PACKAGE}.{tool}: {message}"))
181 }
182 ToolCallError::Denied { message } => crate::runtime::host::permission_denied_by_harness(
183 principal,
184 TOOLS_CAPABILITY,
185 format!("{TOOLS_PACKAGE}.{tool}: {message}"),
186 ),
187 ToolCallError::TimedOut => crate::runtime::host::timeout_error(format!(
188 "{TOOLS_PACKAGE}.{tool}: the application did not answer in time"
189 )),
190 ToolCallError::QuotaExceeded { message } => {
191 crate::runtime::host::quota_exceeded_error(format!("{TOOLS_PACKAGE}.{tool}: {message}"))
192 }
193 ToolCallError::Cancelled => crate::runtime::host::fatal_host_error(format!(
194 "{TOOLS_PACKAGE}.{tool}: the call was cancelled"
195 )),
196 }
197}
198
199pub fn tools_call_package_declaration() -> PackageDeclaration {
202 let mut defs = PackageDeclaration::with_package(TOOLS_MODULE_NAME);
203 defs.values.insert(
204 "call".to_string(),
205 ValueSymbol {
206 name: "call".to_string(),
207 mangled_name: crate::mangle::host(TOOLS_MODULE_NAME, "call"),
208 declaration_span: Span::at(crate::FileId::MCP),
209 kind: ValueKind::Function {
210 generics: Vec::new(),
211 params: vec![
212 Param::new("tool", Type::String),
213 Param::new("args", Type::String),
214 ],
215 ret: Type::Unknown,
216 type_predicate: None,
217 doc: None,
218 },
219 },
220 );
221 defs
222}
223
224#[cfg(test)]
225mod tests {
226 use std::collections::BTreeMap;
227 use std::sync::{Arc, Mutex};
228 use std::time::Duration;
229
230 use wasmtime::{Linker, Module};
231
232 use super::{ToolCallError, ToolProvider};
233 use crate::runtime::McpResponse;
234 use crate::runtime::host::DenialSource;
235 use crate::runtime::security::{CheckOutcome, SecurityCheck};
236 use crate::runtime::{
237 RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
238 install_tenant_limits,
239 };
240 use crate::{
241 ObjectField, PackageDeclaration, Param, Shape, Span, Type, ValueKind, ValueSymbol,
242 VirtualDispatch, compile_script, mangle,
243 };
244
245 fn tools_package() -> PackageDeclaration {
248 let pkg = super::TOOLS_PACKAGE;
249 let mut defs = PackageDeclaration::with_package(pkg);
250 defs.virtual_dispatch = Some(VirtualDispatch::Tools);
251 let object = |field: &str| Type::Object {
252 index: None,
253 fields: BTreeMap::from([(field.to_string(), ObjectField::required(Type::String))]),
254 };
255 let (args, ret) = (object("msg"), object("reply"));
256 defs.shapes.push(Shape::from_type(&args).unwrap());
257 defs.shapes.push(Shape::from_type(&ret).unwrap());
258 let mut insert = |name: &str, params: Vec<Param>, ret: Type| {
259 defs.values.insert(
260 name.to_string(),
261 ValueSymbol {
262 name: name.to_string(),
263 mangled_name: mangle::host(pkg, name),
264 declaration_span: Span::at(crate::FileId::MCP),
265 kind: ValueKind::Function {
266 generics: Vec::new(),
267 params,
268 ret,
269 type_predicate: None,
270 doc: None,
271 },
272 },
273 );
274 };
275 insert("echo", vec![Param::new("args", args)], ret);
276 insert("raw", Vec::new(), Type::Unknown);
277 defs
278 }
279
280 type Calls = Arc<Mutex<Vec<(String, String)>>>;
281
282 struct Answer {
284 answer: Result<serde_json::Value, ToolCallError>,
285 delay: Duration,
286 calls: Calls,
287 }
288
289 impl ToolProvider for Answer {
290 fn call<'a>(
291 &'a self,
292 tool: &'a str,
293 args_json: &'a str,
294 ) -> std::pin::Pin<
295 Box<dyn std::future::Future<Output = Result<McpResponse, ToolCallError>> + Send + 'a>,
296 > {
297 self.calls
298 .lock()
299 .unwrap()
300 .push((tool.to_string(), args_json.to_string()));
301 Box::pin(async move {
302 tokio::time::sleep(self.delay).await;
303 let mut value = self.answer.clone()?;
304 Ok(McpResponse::take(&mut value).expect("bounded"))
305 })
306 }
307 }
308
309 struct DenyAll;
310 impl SecurityCheck for DenyAll {
311 fn check(&self, _: &str, capability: &str, _: &serde_json::Value) -> CheckOutcome {
312 CheckOutcome::Deny {
313 rule: None,
314 reason: format!("denied {capability}"),
315 }
316 }
317 }
318
319 struct Run {
320 result: wasmtime::Result<Option<String>>,
321 calls: Calls,
322 clock_paused: Duration,
323 }
324
325 async fn run_with(
326 source: &str,
327 answer: Result<serde_json::Value, ToolCallError>,
328 delay: Duration,
329 security: Option<Arc<dyn SecurityCheck>>,
330 provided: bool,
331 ) -> Run {
332 let pkg = tools_package();
333 let compiled =
334 compile_script(source, "<test>", crate::FileId(0), &[], &[&pkg]).expect("compiles");
335 let cfg = RuntimeConfig::default();
336 let engine = cfg.engine().unwrap();
337 let calls = Calls::default();
338 let mut data = StoreData::with_vfs(Vfs::none());
339 data.install_type_info(compiled.type_info.clone());
340 if provided {
341 data.tool_provider = Some(Arc::new(Answer {
342 answer,
343 delay,
344 calls: calls.clone(),
345 }));
346 }
347 if let Some(check) = security {
348 data.security_check = check;
349 }
350 let mut store = cfg.store_async(&engine, data).unwrap();
351 install_tenant_limits(&mut store);
352 let module = Module::new(&engine, &compiled.wasm).unwrap();
353 let mut linker = Linker::<StoreData>::new(&engine);
354 install_runtime_async(&mut linker, &mut store)
355 .await
356 .unwrap();
357 let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
358 let result = dispatch_main_async(&mut store, &instance).await;
359 Run {
360 result,
361 calls,
362 clock_paused: store.data().clock_paused,
363 }
364 }
365
366 async fn run(source: &str, answer: Result<serde_json::Value, ToolCallError>) -> Run {
367 run_with(source, answer, Duration::ZERO, None, true).await
368 }
369
370 fn escaped_source(error: &wasmtime::Error) -> Option<DenialSource> {
371 error
372 .downcast_ref::<crate::backtrace::ThrownError>()
373 .and_then(|thrown| thrown.denial.as_ref())
374 .map(|denial| denial.source)
375 }
376
377 #[tokio::test]
378 async fn typed_tool_round_trips_through_the_provider() {
379 let run = run(
380 r#"import tools from "@tools";
381 function main(): string { return tools.echo({ msg: "hi" }).reply; }"#,
382 Ok(serde_json::json!({ "reply": "hello" })),
383 )
384 .await;
385 assert_eq!(run.result.unwrap().as_deref(), Some("hello"));
386 assert_eq!(
387 *run.calls.lock().unwrap(),
388 [("echo".to_string(), r#"{"msg":"hi"}"#.to_string())]
389 );
390 }
391
392 #[tokio::test]
393 async fn a_zero_argument_tool_sends_an_empty_object_and_returns_unknown() {
394 let run = run(
395 r#"import { raw } from "@tools";
396 function main(): string { return JSON.stringify(raw()) ?? ""; }"#,
397 Ok(serde_json::json!([1, "two"])),
398 )
399 .await;
400 assert_eq!(run.result.unwrap().as_deref(), Some(r#"[1,"two"]"#));
401 assert_eq!(run.calls.lock().unwrap()[0].1, "{}");
402 }
403
404 #[tokio::test]
405 async fn a_value_that_does_not_match_the_declared_type_throws_type_error() {
406 let run = run(
407 r#"import tools from "@tools";
408 function main(): string {
409 try { return tools.echo({ msg: "hi" }).reply; }
410 catch (e) { return e instanceof TypeError ? "type" : "other"; }
411 }"#,
412 Ok(serde_json::json!({ "reply": 5 })),
413 )
414 .await;
415 assert_eq!(run.result.unwrap().as_deref(), Some("type"));
416 }
417
418 #[tokio::test]
419 async fn a_policy_denial_throws_before_the_harness_hears_of_the_call() {
420 let run = run_with(
421 r#"import tools from "@tools";
422 function main(): void { tools.raw(); }"#,
423 Ok(serde_json::Value::Null),
424 Duration::ZERO,
425 Some(Arc::new(DenyAll)),
426 true,
427 )
428 .await;
429 let error = run.result.unwrap_err();
430 assert_eq!(escaped_source(&error), Some(DenialSource::Policy));
431 assert!(run.calls.lock().unwrap().is_empty());
432 }
433
434 #[tokio::test]
435 async fn each_harness_answer_throws_its_own_error_class() {
436 let cases = [
437 (
438 ToolCallError::Failed {
439 message: "no such order".into(),
440 },
441 "e instanceof Error && e.message == \"@tools.raw: no such order\"",
442 ),
443 (
444 ToolCallError::Denied {
445 message: "refused".into(),
446 },
447 "e instanceof PermissionDeniedError",
448 ),
449 (ToolCallError::TimedOut, "e instanceof TimeoutError"),
450 (
451 ToolCallError::QuotaExceeded {
452 message: "full".into(),
453 },
454 "e instanceof QuotaExceededError",
455 ),
456 ];
457 for (answer, check) in cases {
458 let source = format!(
459 r#"import tools from "@tools";
460 function main(): string {{
461 try {{ tools.raw(); return "returned"; }}
462 catch (e) {{ return {check} ? "caught" : "wrong"; }}
463 }}"#
464 );
465 let run = run(&source, Err(answer.clone())).await;
466 assert_eq!(run.result.unwrap().as_deref(), Some("caught"), "{answer:?}");
467 }
468 }
469
470 #[tokio::test]
471 async fn an_escaped_harness_refusal_reports_source_harness() {
472 let run = run(
473 r#"import tools from "@tools";
474 function main(): void { tools.raw(); }"#,
475 Err(ToolCallError::Denied {
476 message: "a person said no".into(),
477 }),
478 )
479 .await;
480 let error = run.result.unwrap_err();
481 assert_eq!(escaped_source(&error), Some(DenialSource::Harness));
482 }
483
484 #[tokio::test]
485 async fn a_cancelled_call_ends_the_run_uncaught() {
486 let run = run(
487 r#"import tools from "@tools";
488 function main(): string { try { tools.raw(); } catch (e) { return "caught"; } return "returned"; }"#,
489 Err(ToolCallError::Cancelled),
490 )
491 .await;
492 let error = run.result.unwrap_err();
493 assert!(
494 error.is::<crate::runtime::host::FatalHostError>(),
495 "{error:?}"
496 );
497 }
498
499 #[tokio::test]
500 async fn without_a_provider_the_call_throws_a_catchable_error() {
501 let run = run_with(
502 r#"import tools from "@tools";
503 function main(): string { try { tools.raw(); } catch (e) { return e.message; } return ""; }"#,
504 Ok(serde_json::Value::Null),
505 Duration::ZERO,
506 None,
507 false,
508 )
509 .await;
510 assert_eq!(
511 run.result.unwrap().as_deref(),
512 Some("@tools.raw: this session offers no application tools")
513 );
514 }
515
516 #[tokio::test]
517 async fn time_spent_waiting_for_the_harness_is_recorded_as_paused() {
518 let run = run_with(
519 r#"import tools from "@tools";
520 function main(): void { tools.raw(); tools.raw(); }"#,
521 Ok(serde_json::Value::Null),
522 Duration::from_millis(30),
523 None,
524 true,
525 )
526 .await;
527 run.result.unwrap();
528 assert!(
529 run.clock_paused >= Duration::from_millis(60),
530 "{:?}",
531 run.clock_paused
532 );
533 }
534
535 #[test]
536 fn importing_tools_without_a_catalog_is_a_compile_error() {
537 let error = compile_script(
538 r#"import tools from "@tools"; function main(): void {}"#,
539 "<test>",
540 crate::FileId(0),
541 &[],
542 &[],
543 )
544 .unwrap_err();
545 assert!(
546 format!("{error:?}").contains("`@tools` is unavailable"),
547 "{error:?}"
548 );
549 }
550
551 #[test]
552 fn the_import_is_reported_as_tools() {
553 let parsed = crate::compile::parse_script(
554 r#"import tools from "@tools"; import x from "@mcp/x"; function main(): void {}"#,
555 crate::FileId(0),
556 );
557 let imports = parsed.external_imports().unwrap();
558 assert!(imports.tools);
559 assert!(imports.registry_packages.is_empty());
560 assert_eq!(imports.mcp_servers.len(), 1);
561 }
562}