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