tokitai_core/dynamic.rs
1//! T-010: Dynamic / runtime-mutable tool registry.
2//!
3//! The compile-time [`crate::ToolProvider`] trait returns `&'static [ToolDefinition]`,
4//! which by definition cannot grow or shrink at runtime. That rigidity is
5//! the right default for the macro hot path, but it blocks legitimate
6//! multi-tenant use cases: per-tenant allow-lists, hot-reload of tool
7//! plugins, AB-test arms that expose a different toolset to each user.
8//!
9//! This module adds a separate trait, [`DynamicToolProvider`], with a
10//! concrete [`DynamicToolRegistry`] backing store. The macro-generated
11//! providers do **not** implement it (that would defeat the compile-time
12//! guarantee); instead, [`DynamicToolRegistry`] wraps a heterogeneous
13//! `Vec<Box<dyn ToolCallerDyn>>` and exposes `add_tool`, `remove_tool`,
14//! `enable_for`, and `disable_for` so callers can mutate the visible
15//! toolset at runtime.
16//!
17//! The trait is **additive** — existing `ToolProvider` impls continue to
18//! work unchanged. The struct is `Send + Sync` so it can be shared
19//! between axum handlers in the MCP server.
20
21use std::collections::{HashMap, HashSet};
22use std::sync::{Arc, RwLock};
23
24use crate::serde_types::Value;
25use crate::{ToolDefinition, ToolError, ToolErrorKind};
26
27/// T-010: trait for runtime-mutable tool registries.
28///
29/// Distinct from the compile-time [`crate::ToolProvider`] trait. Existing macro-
30/// generated providers do not implement this — the macro's whole value
31/// proposition is "schema is fixed at compile time." [`DynamicToolProvider`]
32/// is opt-in: callers who need mutability build a
33/// [`DynamicToolRegistry`] (or implement this trait themselves) and
34/// serve tools through it.
35///
36/// The trait surface is deliberately small and matches the pain points
37/// in PP-B1:
38///
39/// * `add_tool` / `remove_tool` — global registration, takes effect for
40/// every caller.
41/// * `enable_for` / `disable_for` — per-tenant overrides layered on top
42/// of the global registry.
43///
44/// Per-call dispatch (`call_tool`) goes through the existing
45/// [`crate::ToolCaller`] trait; [`DynamicToolRegistry`] implements both.
46///
47/// # Example
48///
49/// ```rust,ignore
50/// use tokitai_core::{DynamicToolProvider, DynamicToolRegistry, ToolDefinition, ToolError};
51/// use serde_json::json;
52///
53/// let mut reg = DynamicToolRegistry::new();
54/// reg.add_tool(
55/// "add",
56/// ToolDefinition::new("add", "Add two numbers", r#"{"type":"object"}"#),
57/// Arc::new(|_args| Ok(json!(3))),
58/// );
59/// let v = reg.call_tool("add", &json!({"a":1,"b":2})).unwrap();
60/// assert_eq!(v, json!(3));
61/// ```
62///
63/// T-010: handler signature for dynamically-registered tools.
64///
65/// The closure receives the JSON args object the caller supplied and
66/// must return either the result or a [`ToolError`]. Implementations
67/// can `Arc::new(|args| { ... })` any `Fn(&Value) -> Result<Value,
68/// ToolError> + Send + Sync` closure.
69pub type DynamicHandler = Arc<dyn Fn(&Value) -> Result<Value, ToolError> + Send + Sync>;
70
71/// T-010: trait for runtime-mutable tool registries.
72///
73/// Distinct from the compile-time [`crate::ToolProvider`] trait.
74/// Existing macro-generated providers do not implement this — the
75/// macro's whole value proposition is "schema is fixed at compile
76/// time." [`DynamicToolProvider`] is opt-in: callers who need
77/// mutability build a [`DynamicToolRegistry`] (or implement this
78/// trait themselves) and serve tools through it.
79///
80/// The trait surface is deliberately small and matches the pain
81/// points in PP-B1:
82///
83/// * `add_tool` / `remove_tool` — global registration, takes effect
84/// for every caller.
85/// * `enable_for` / `disable_for` — per-tenant overrides layered on
86/// top of the global registry.
87///
88/// Per-call dispatch (`call_tool`) goes through the existing
89/// [`crate::ToolCaller`] trait; [`DynamicToolRegistry`] implements
90/// both.
91pub trait DynamicToolProvider {
92 /// Register a tool under `name`. Replaces any existing registration
93 /// under the same name. Returns the registration's [`ToolDefinition`]
94 /// for inspection / chaining.
95 ///
96 /// `handler` is the closure that actually runs when the tool is
97 /// invoked; it receives the JSON args object and must return either
98 /// the result JSON or a [`ToolError`].
99 fn add_tool(&mut self, name: &str, definition: ToolDefinition, handler: DynamicHandler);
100
101 /// Remove a tool from the global registry. Returns `true` when the
102 /// tool existed and was removed; `false` when no such tool was
103 /// registered.
104 fn remove_tool(&mut self, name: &str) -> bool;
105
106 /// Enable `name` for a specific `tenant`. The tool must already be
107 /// in the global registry (use [`DynamicToolProvider::add_tool`]
108 /// first); this method only flips the per-tenant visibility flag.
109 ///
110 /// A tenant id is any `&str` the caller picks (user id, API key,
111 /// session id, etc.); it is opaque to the registry.
112 fn enable_for(&mut self, name: &str, tenant: &str);
113
114 /// Disable `name` for a specific `tenant`. Has no effect on the
115 /// global registry; other tenants keep their visibility.
116 fn disable_for(&mut self, name: &str, tenant: &str);
117
118 /// List the tools visible to `tenant` (or, when `tenant` is `None`,
119 /// every globally-registered tool). Useful for diagnostics / REST
120 /// `/tools` endpoints.
121 fn visible_tools(&self, tenant: Option<&str>) -> Vec<ToolDefinition>;
122}
123
124/// T-010: concrete dynamic registry.
125///
126/// Thread-safe (`Arc<RwLock<...>>` internals) so it can be shared
127/// between axum handlers in `tokitai-mcp-server` and a long-running
128/// admin endpoint that adds/removes tools at runtime.
129///
130/// Tool definitions live in an `Inner` struct behind an `Arc<RwLock>`.
131/// Per-tenant overrides are layered on top: `enable_for` /
132/// `disable_for` mutate a `HashMap<tenant, HashSet<tool_name>>`. A
133/// tool is visible to a tenant when:
134///
135/// 1. it is globally registered, AND
136/// 2. the tenant has no entry in the overrides map (default-allow), OR
137/// 3. the tenant's entry contains the tool name.
138///
139/// Note: when a tenant's first override is a `disable_for`, that flips
140/// the default-allow to default-deny for that tenant. Callers wanting
141/// to keep default-allow semantics should `enable_for` every global
142/// tool after the first `disable_for`.
143#[derive(Default, Clone)]
144pub struct DynamicToolRegistry {
145 inner: Arc<RwLock<Inner>>,
146}
147
148#[derive(Default)]
149struct Inner {
150 /// Globally-registered tools (always visible unless a tenant
151 /// override disables them).
152 tools: HashMap<String, RegisteredTool>,
153 /// Per-tenant allow/deny sets. `Some(set)` means "the tenant's
154 /// visibility is `set`" — empty `set` = deny-all for that tenant.
155 /// `None` means "use the global default (allow-all)".
156 per_tenant: HashMap<String, HashSet<String>>,
157}
158
159struct RegisteredTool {
160 definition: ToolDefinition,
161 handler: DynamicHandler,
162}
163
164impl DynamicToolRegistry {
165 /// Create an empty registry.
166 ///
167 /// # Example
168 ///
169 /// ```rust
170 /// use tokitai_core::DynamicToolRegistry;
171 ///
172 /// let reg = DynamicToolRegistry::new();
173 /// assert!(reg.list_global().is_empty());
174 /// ```
175 #[must_use]
176 pub fn new() -> Self {
177 Self::default()
178 }
179
180 /// List the names of all globally-registered tools, in
181 /// insertion order (HashMap ordering is unspecified, but the set
182 /// itself is deterministic).
183 pub fn list_global(&self) -> Vec<String> {
184 self.inner
185 .read()
186 .map(|guard| guard.tools.keys().cloned().collect())
187 .unwrap_or_default()
188 }
189
190 /// Return `true` if `name` is currently registered (globally).
191 pub fn contains(&self, name: &str) -> bool {
192 self.inner
193 .read()
194 .map(|guard| guard.tools.contains_key(name))
195 .unwrap_or(false)
196 }
197
198 /// Return the [`ToolDefinition`] for a globally-registered tool,
199 /// or `None` if it is not registered.
200 pub fn definition(&self, name: &str) -> Option<ToolDefinition> {
201 self.inner
202 .read()
203 .ok()
204 .and_then(|guard| guard.tools.get(name).map(|t| t.definition.clone()))
205 }
206
207 /// Drop all globally-registered tools and per-tenant overrides.
208 /// Useful for tests and for "reset to known state" admin flows.
209 pub fn clear(&self) {
210 if let Ok(mut guard) = self.inner.write() {
211 guard.tools.clear();
212 guard.per_tenant.clear();
213 }
214 }
215
216 /// Invoke `name` honouring `tenant`'s visibility rules.
217 /// Returns `ToolError::NotFound` when the tool is not visible to
218 /// `tenant` (whether because it was never registered, or because
219 /// the tenant's override set excludes it).
220 pub fn call_for_tenant(
221 &self,
222 name: &str,
223 tenant: Option<&str>,
224 args: &Value,
225 ) -> Result<Value, ToolError> {
226 let guard = self
227 .inner
228 .read()
229 .map_err(|e| ToolError::internal_error(format!("registry poisoned: {}", e)))?;
230 let tool = guard
231 .tools
232 .get(name)
233 .ok_or_else(|| ToolError::not_found(format!("tool `{}` is not registered", name)))?;
234 if let Some(t) = tenant {
235 if let Some(set) = guard.per_tenant.get(t) {
236 if !set.contains(name) {
237 return Err(ToolError::not_found(format!(
238 "tool `{}` is not enabled for tenant `{}`",
239 name, t
240 )));
241 }
242 }
243 }
244 (tool.handler)(args)
245 }
246}
247
248impl DynamicToolProvider for DynamicToolRegistry {
249 fn add_tool(&mut self, name: &str, definition: ToolDefinition, handler: DynamicHandler) {
250 // The trait signature takes `&mut self`, but our backing
251 // store is `Arc<RwLock<...>>` so callers can share the
252 // registry across threads. Deref to `&self` for the actual
253 // mutation; `Arc::make_mut` would be cleaner but the lock
254 // pattern is more uniform with the rest of the API.
255 if let Ok(mut guard) = self.inner.write() {
256 guard.tools.insert(
257 name.to_string(),
258 RegisteredTool {
259 definition,
260 handler,
261 },
262 );
263 }
264 }
265
266 fn remove_tool(&mut self, name: &str) -> bool {
267 if let Ok(mut guard) = self.inner.write() {
268 // Also scrub the name from every tenant's allow-set so
269 // we don't leak stale names into future visibility
270 // queries. If a tenant's set becomes empty as a result,
271 // drop the entry entirely so the tenant falls back to
272 // default-allow semantics.
273 for (tenant, set) in guard.per_tenant.iter_mut() {
274 set.remove(name);
275 let _ = tenant; // used by the entry-removal pass below
276 }
277 guard.per_tenant.retain(|_tenant, set| !set.is_empty());
278 guard.tools.remove(name).is_some()
279 } else {
280 false
281 }
282 }
283
284 fn enable_for(&mut self, name: &str, tenant: &str) {
285 let Ok(mut guard) = self.inner.write() else {
286 return;
287 };
288 // No-op when the tool isn't globally registered; matches the
289 // documented contract that `enable_for` only flips visibility
290 // for already-registered tools.
291 if !guard.tools.contains_key(name) {
292 return;
293 }
294 let entry = guard
295 .per_tenant
296 .entry(tenant.to_string())
297 .or_insert_with(HashSet::new);
298 entry.insert(name.to_string());
299 }
300
301 fn disable_for(&mut self, name: &str, tenant: &str) {
302 let Ok(mut guard) = self.inner.write() else {
303 return;
304 };
305 // Disabling a tool for a tenant implicitly flips the tenant
306 // to "default-deny" semantics: we insert an empty allow-set
307 // if the tenant doesn't have an override yet, then remove
308 // the tool name. This is the only way to take a globally-
309 // visible tool away from a tenant without affecting other
310 // tenants.
311 let entry = guard
312 .per_tenant
313 .entry(tenant.to_string())
314 .or_insert_with(HashSet::new);
315 entry.remove(name);
316 }
317
318 fn visible_tools(&self, tenant: Option<&str>) -> Vec<ToolDefinition> {
319 let Ok(guard) = self.inner.read() else {
320 return Vec::new();
321 };
322 match tenant {
323 None => guard.tools.values().map(|t| t.definition.clone()).collect(),
324 Some(t) => {
325 if let Some(set) = guard.per_tenant.get(t) {
326 guard
327 .tools
328 .values()
329 .filter(|tool| set.contains(&tool.definition.name))
330 .map(|t| t.definition.clone())
331 .collect()
332 } else {
333 // Tenant has no overrides: default-allow all
334 // globally-registered tools.
335 guard.tools.values().map(|t| t.definition.clone()).collect()
336 }
337 }
338 }
339 }
340}
341
342// -----------------------------------------------------------------------------
343// ToolProvider / ToolCaller integration so the registry plugs into existing
344// transports (MCP HTTP server, stdio transport, etc.) without a separate
345// wrapper.
346// -----------------------------------------------------------------------------
347
348impl crate::ToolProvider for DynamicToolRegistry {
349 /// The compile-time static slice is empty by design — the
350 /// registry is purely runtime. Servers that need the list call
351 /// [`DynamicToolProvider::visible_tools`] instead.
352 fn tool_definitions() -> &'static [ToolDefinition] {
353 // T-010: We can't return a `&'static` slice to the runtime
354 // HashMap without leaking memory. Returning the empty
355 // `&'static` slice is the documented contract for
356 // "toolset is dynamic; ask via the instance method." The
357 // MCP server already special-cases `MultiToolProvider` for
358 // exactly this reason.
359 const EMPTY: &[ToolDefinition] = &[];
360 EMPTY
361 }
362}
363
364impl crate::ToolCaller for DynamicToolRegistry {
365 fn call_tool(&self, name: &str, args: &Value) -> Result<Value, ToolError> {
366 // No tenant context at this layer — every dynamically-
367 // registered tool is reachable when the caller has the
368 // registry handle. Multi-tenant gating is layered on top
369 // via [`DynamicToolRegistry::call_for_tenant`].
370 self.call_for_tenant(name, None, args)
371 }
372}
373
374/// T-023: dynamic registries inherit the trait's default
375/// (empty) capability manifest. Operators who need a
376/// per-tenant capability allowlist for a dynamic registry can
377/// wrap it in a `McpServerBuilder::with_tool(registry)` and
378/// configure the allowlist on the builder. The static
379/// `#[tool]` macro path is the primary T-023 surface; the
380/// dynamic path is left as a follow-up (the per-tenant gating
381/// already provides a richer policy surface than the static
382/// manifest, so collapsing the two would be a net loss).
383impl crate::CapabilityManifestProvider for DynamicToolRegistry {}
384
385/// T-010: per-tenant error variant for the case where a tool exists
386/// globally but is gated off for the caller. Distinct from
387/// [`ToolErrorKind::NotFound`] so callers can distinguish "I never
388/// registered this tool" from "you can't use this tool" when
389/// debugging per-tenant allow-list misconfigurations.
390pub const TENANT_DENIED_KIND_HINT: &str = "tenant-denied";
391
392/// Helper to extract the per-tenant `not_found` reason from a
393/// [`ToolError`]. Returns `true` when the error is the per-tenant
394/// gating flavour (i.e. the message starts with
395/// `"tool \`X\` is not enabled for tenant \`Y\`"`).
396pub fn is_tenant_denied(err: &ToolError) -> bool {
397 err.kind == ToolErrorKind::NotFound && err.message.contains("is not enabled for tenant")
398}
399
400// -----------------------------------------------------------------------------
401// Tests
402// -----------------------------------------------------------------------------
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::ToolCaller;
408 use crate::ToolProvider;
409 use serde_json::json;
410
411 fn add_handler(a: i64, b: i64) -> DynamicHandler {
412 Arc::new(move |_args| Ok(json!(a + b)))
413 }
414
415 #[test]
416 fn add_and_call() {
417 let mut reg = DynamicToolRegistry::new();
418 reg.add_tool(
419 "add",
420 ToolDefinition::new("add", "Add", r#"{"type":"object"}"#),
421 add_handler(1, 2),
422 );
423 let v = reg.call_tool("add", &json!({})).unwrap();
424 assert_eq!(v, json!(3));
425 assert!(reg.contains("add"));
426 assert_eq!(reg.list_global(), vec!["add".to_string()]);
427 }
428
429 #[test]
430 fn remove_tool_returns_true_then_false() {
431 let mut reg = DynamicToolRegistry::new();
432 reg.add_tool(
433 "x",
434 ToolDefinition::new("x", "x", "{}"),
435 Arc::new(|_| Ok(json!(null))),
436 );
437 assert!(reg.remove_tool("x"));
438 assert!(!reg.remove_tool("x"));
439 }
440
441 #[test]
442 fn enable_disable_for_tenant() {
443 let mut reg = DynamicToolRegistry::new();
444 reg.add_tool(
445 "t",
446 ToolDefinition::new("t", "t", "{}"),
447 Arc::new(|_| Ok(json!("ok"))),
448 );
449
450 // Default: tenant A sees the tool.
451 assert!(reg.call_for_tenant("t", Some("a"), &json!({})).is_ok());
452 // disable_for("t", "a") flips A to default-deny.
453 reg.disable_for("t", "a");
454 let err = reg.call_for_tenant("t", Some("a"), &json!({})).unwrap_err();
455 assert!(is_tenant_denied(&err));
456 // Other tenant still sees the tool.
457 assert!(reg.call_for_tenant("t", Some("b"), &json!({})).is_ok());
458 // enable_for restores access.
459 reg.enable_for("t", "a");
460 assert!(reg.call_for_tenant("t", Some("a"), &json!({})).is_ok());
461 }
462
463 #[test]
464 fn enable_for_unknown_tool_is_noop() {
465 let mut reg = DynamicToolRegistry::new();
466 reg.enable_for("nope", "a");
467 // No crash, no entry created.
468 assert!(!reg.contains("nope"));
469 }
470
471 #[test]
472 fn remove_tool_clears_per_tenant_set() {
473 let mut reg = DynamicToolRegistry::new();
474 reg.add_tool(
475 "t",
476 ToolDefinition::new("t", "t", "{}"),
477 Arc::new(|_| Ok(json!(null))),
478 );
479 reg.enable_for("t", "a");
480 assert!(reg.remove_tool("t"));
481 // After removal, the per-tenant set should not still mention
482 // `t`. We probe by re-adding and ensuring visibility resets.
483 reg.add_tool(
484 "t",
485 ToolDefinition::new("t", "t", "{}"),
486 Arc::new(|_| Ok(json!(null))),
487 );
488 // Tenant A's per-tenant set was scrubbed, so they see `t`
489 // again via default-allow semantics.
490 assert!(reg.call_for_tenant("t", Some("a"), &json!({})).is_ok());
491 }
492
493 #[test]
494 fn visible_tools_filters_by_tenant() {
495 let mut reg = DynamicToolRegistry::new();
496 reg.add_tool(
497 "a",
498 ToolDefinition::new("a", "a", "{}"),
499 Arc::new(|_| Ok(json!(null))),
500 );
501 reg.add_tool(
502 "b",
503 ToolDefinition::new("b", "b", "{}"),
504 Arc::new(|_| Ok(json!(null))),
505 );
506 let all = reg.visible_tools(None);
507 assert_eq!(all.len(), 2);
508
509 reg.disable_for("a", "alice");
510 reg.disable_for("b", "alice");
511 let alice = reg.visible_tools(Some("alice"));
512 assert!(alice.is_empty());
513
514 reg.enable_for("b", "alice");
515 let alice = reg.visible_tools(Some("alice"));
516 assert_eq!(alice.len(), 1);
517 assert_eq!(alice[0].name, "b");
518 }
519
520 #[test]
521 fn tool_provider_static_slice_is_empty() {
522 // T-010 contract: the dynamic registry's static slice is
523 // empty by design. Server code must call the instance
524 // method to discover tools.
525 assert!(DynamicToolRegistry::tool_definitions().is_empty());
526 }
527
528 #[test]
529 fn clear_resets_state() {
530 let mut reg = DynamicToolRegistry::new();
531 reg.add_tool(
532 "x",
533 ToolDefinition::new("x", "x", "{}"),
534 Arc::new(|_| Ok(json!(null))),
535 );
536 reg.enable_for("x", "a");
537 reg.clear();
538 assert!(reg.list_global().is_empty());
539 assert!(reg.visible_tools(Some("a")).is_empty());
540 }
541
542 #[test]
543 fn missing_tool_returns_not_found() {
544 let reg = DynamicToolRegistry::new();
545 let err = reg.call_tool("ghost", &json!({})).unwrap_err();
546 assert_eq!(err.kind, ToolErrorKind::NotFound);
547 }
548
549 #[test]
550 fn handler_error_propagates() {
551 let mut reg = DynamicToolRegistry::new();
552 reg.add_tool(
553 "boom",
554 ToolDefinition::new("boom", "boom", "{}"),
555 Arc::new(|_| Err(ToolError::internal_error("kaboom"))),
556 );
557 let err = reg.call_tool("boom", &json!({})).unwrap_err();
558 assert_eq!(err.kind, ToolErrorKind::InternalError);
559 assert!(err.message.contains("kaboom"));
560 }
561
562 #[test]
563 fn registry_is_send_sync() {
564 // Compile-time assertion: DynamicToolRegistry must be
565 // Send + Sync so it can live behind an Arc<McpServerWithProvider<T>>.
566 fn assert_send_sync<T: Send + Sync>() {}
567 assert_send_sync::<DynamicToolRegistry>();
568 }
569}