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