tenzro_types/tool.rs
1//! Tool registry types for Tenzro Network
2//!
3//! Defines types for the decentralized tools registry where
4//! agents and providers can publish MCP server endpoints and other
5//! tools for others to discover, install, and invoke autonomously.
6//!
7//! Tools are MCP servers (or API/native endpoints) that agents use to
8//! take actions in the world. Skills teach agents HOW to use tools;
9//! tools ARE the actual capabilities being invoked.
10//!
11//! Three transport modes are supported for MCP servers:
12//!
13//! - `mcp` — remote MCP over JSON-RPC 2.0 Streamable HTTP (POST). This is
14//! how hosted MCPs ship today (Anthropic-hosted MCPs, partner MCPs).
15//! - `mcp-stdio` — local MCP subprocess. The operator declares the
16//! command + args + env vars in `spawn_spec`; the node spawns and
17//! supervises the subprocess and speaks JSON-RPC over stdin/stdout.
18//! This is how most third-party MCPs ship (Stripe MCP, GitHub MCP,
19//! Notion MCP, Linear MCP, Slack MCP, etc.).
20//! - `mcp-sse` — legacy SSE transport. Some older MCPs still ship this.
21//!
22//! For all three modes, the operator's upstream credentials (their
23//! Stripe secret, OpenAI key, etc.) are injected into the MCP via
24//! `upstream_auth`, sealed at rest, and NEVER exposed to the tenant
25//! that presents a Tenzro API key. The tenant only sees the MCP's
26//! tool output.
27
28use crate::primitives::Address;
29use serde::{Deserialize, Serialize};
30
31/// Transport mode for an MCP / tool resource.
32///
33/// Old code that stored `tool_type: String` is preserved via
34/// `ToolDefinition::tool_type`; new code should prefer
35/// `transport_mode` which is strongly typed. The two are kept in sync
36/// at write time via `ToolDefinition::set_transport_mode`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum ToolTransportMode {
40 /// Remote MCP over JSON-RPC 2.0 Streamable HTTP POST.
41 Mcp,
42 /// Local MCP subprocess speaking JSON-RPC over stdin/stdout.
43 /// Requires `spawn_spec` to be set.
44 McpStdio,
45 /// Legacy MCP over Server-Sent Events.
46 McpSse,
47 /// OpenAPI-compatible REST endpoint (POST JSON body).
48 Api,
49 /// Built-in node capability — handled inline.
50 Native,
51}
52
53impl ToolTransportMode {
54 /// Wire-format string for the legacy `tool_type` field. Used to
55 /// keep old clients reading the type as a string.
56 pub fn as_str(&self) -> &'static str {
57 match self {
58 ToolTransportMode::Mcp => "mcp",
59 ToolTransportMode::McpStdio => "mcp-stdio",
60 ToolTransportMode::McpSse => "mcp-sse",
61 ToolTransportMode::Api => "api",
62 ToolTransportMode::Native => "native",
63 }
64 }
65
66 /// Inverse of `as_str`. Returns `None` for unknown strings rather
67 /// than panicking so the registry can refuse to load unknown
68 /// transport modes safely.
69 pub fn parse_str(s: &str) -> Option<Self> {
70 match s {
71 "mcp" => Some(ToolTransportMode::Mcp),
72 "mcp-stdio" => Some(ToolTransportMode::McpStdio),
73 "mcp-sse" => Some(ToolTransportMode::McpSse),
74 "api" => Some(ToolTransportMode::Api),
75 "native" => Some(ToolTransportMode::Native),
76 _ => None,
77 }
78 }
79}
80
81/// How the operator's upstream credentials are injected into an MCP
82/// invocation. The actual secret is NOT stored in this struct — it is
83/// stored separately by the node in a sealed credential vault, keyed
84/// by `sealed_secret_ref`. The operator's secret is fetched only at
85/// invocation time and zeroized after the request completes.
86///
87/// Tenants who present a Tenzro API key never see `sealed_secret_ref`
88/// or the underlying secret. They see only the MCP's response payload.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(tag = "kind", rename_all = "snake_case")]
91pub enum UpstreamAuth {
92 /// `Authorization: Bearer <secret>` header on the outbound request.
93 Bearer {
94 /// Opaque reference into the operator's sealed credential
95 /// vault. The node maps this to the actual secret at
96 /// invocation time.
97 sealed_secret_ref: String,
98 },
99 /// Arbitrary header injection: `<header_name>: <secret>`.
100 Header {
101 header_name: String,
102 sealed_secret_ref: String,
103 },
104 /// For stdio MCPs only: secret is injected as an environment
105 /// variable in the spawned subprocess. Common for npm-package
106 /// MCPs that expect e.g. `OPENAI_API_KEY`, `STRIPE_API_KEY`, etc.
107 EnvVar {
108 env_var_name: String,
109 sealed_secret_ref: String,
110 },
111 /// Query-string parameter on the endpoint URL (rare; some legacy
112 /// services). Discouraged for new integrations because of URL
113 /// logging concerns, but supported for completeness.
114 QueryParam {
115 param_name: String,
116 sealed_secret_ref: String,
117 },
118}
119
120impl UpstreamAuth {
121 /// Returns the sealed-secret reference for vault lookup.
122 pub fn sealed_secret_ref(&self) -> &str {
123 match self {
124 UpstreamAuth::Bearer { sealed_secret_ref }
125 | UpstreamAuth::Header {
126 sealed_secret_ref, ..
127 }
128 | UpstreamAuth::EnvVar {
129 sealed_secret_ref, ..
130 }
131 | UpstreamAuth::QueryParam {
132 sealed_secret_ref, ..
133 } => sealed_secret_ref,
134 }
135 }
136}
137
138/// Spawn specification for stdio MCP subprocesses. Required when
139/// `transport_mode == ToolTransportMode::McpStdio`.
140#[derive(Debug, Clone, Default, Serialize, Deserialize)]
141pub struct StdioSpawnSpec {
142 /// Executable command. Looked up via `$PATH` on the operator's
143 /// node. E.g. `npx`, `python`, `/usr/local/bin/stripe-mcp`.
144 pub command: String,
145
146 /// Arguments passed to the command. E.g. `["-y", "@stripe/mcp",
147 /// "--tools=all"]` for the Stripe MCP via npx.
148 pub args: Vec<String>,
149
150 /// Working directory for the subprocess. `None` = node's cwd.
151 pub working_dir: Option<String>,
152
153 /// Environment variables (key → value). The `UpstreamAuth::EnvVar`
154 /// variant injects the operator's sealed credential as an
155 /// additional env var at spawn time — it is NOT stored here.
156 /// Use this map for non-secret config like `LOG_LEVEL`, `REGION`,
157 /// `STRIPE_TEST_MODE`, etc.
158 pub env: std::collections::BTreeMap<String, String>,
159
160 /// Per-call timeout in seconds. Default 30s if `None`.
161 pub timeout_secs: Option<u64>,
162
163 /// Whether to keep the subprocess alive between invocations
164 /// (persistent mode) or spawn-per-call (ephemeral mode). Most MCPs
165 /// support persistent mode and respond faster that way; some legacy
166 /// MCPs require per-call spawn. Default: persistent.
167 #[serde(default = "default_persistent")]
168 pub persistent: bool,
169}
170
171fn default_persistent() -> bool {
172 true
173}
174
175/// Status of a tool in the registry
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178#[derive(Default)]
179pub enum ToolStatus {
180 /// Tool is published and available for use
181 #[default]
182 Active,
183 /// Tool has been deactivated by its creator
184 Inactive,
185 /// Tool has been deprecated (superseded by a newer version)
186 Deprecated,
187}
188
189
190/// A tool (MCP server, API endpoint, or native capability) published
191/// to the Tenzro Network tools registry.
192///
193/// Tools are discovered by agents and invoked to take real-world actions:
194/// web search, code execution, file access, API calls, etc.
195/// Unlike skills (which encode reasoning/logic), tools are the actual
196/// external interfaces agents connect to.
197///
198/// Tool types:
199/// - `"mcp"` — MCP server at `endpoint` (JSON-RPC 2.0 Streamable HTTP)
200/// - `"api"` — OpenAPI-compatible REST endpoint
201/// - `"native"` — built-in node capability
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct ToolDefinition {
204 /// Unique tool identifier (UUID v4)
205 pub tool_id: String,
206
207 /// Human-readable tool name (e.g., "web-search-mcp", "code-executor")
208 pub name: String,
209
210 /// Semantic version string (e.g., "1.0.0")
211 pub version: String,
212
213 /// Tool type: "mcp", "api", or "native"
214 pub tool_type: String,
215
216 /// MCP/API endpoint URL (required for mcp and api types)
217 pub endpoint: String,
218
219 /// Description of what this tool does
220 pub description: String,
221
222 /// Capabilities provided by this tool (e.g., ["web-search", "read-url"])
223 pub capabilities: Vec<String>,
224
225 /// Category for discovery (e.g., "search", "code", "data", "automation")
226 pub category: String,
227
228 /// DID of the agent or human who registered this tool
229 pub creator_did: Option<String>,
230
231 /// Payout wallet for the creator's share of paid invocations.
232 /// **Mandatory** for any non-zero `price_per_call`; registration
233 /// fails (`ToolError::MissingCreatorWallet`) if omitted for a paid
234 /// tool. Free tools (`price_per_call == 0`) may leave this `None`.
235 pub creator_wallet: Option<Address>,
236
237 /// Price per invocation in TNZO atto-tokens (1 TNZO = 10^18 atto).
238 /// Set to `0` for a free tool. The split is identical to the agent
239 /// template marketplace: `MARKETPLACE_COMMISSION_BPS` (5%) to the
240 /// treasury, remainder to `creator_wallet`.
241 pub price_per_call: u128,
242
243 /// Current status of the tool
244 pub status: ToolStatus,
245
246 /// Unix timestamp (seconds) when the tool was registered
247 pub created_at: u64,
248
249 /// Number of times this tool has been invoked
250 pub invocation_count: u64,
251
252 /// Unix timestamp (seconds) of the last liveness signal. Liveness
253 /// sweeper flips `status` to `Inactive` once the tool stays silent past
254 /// the configured TTL. Charitable serde default keeps pre-upgrade rows
255 /// alive until they actually go silent.
256 #[serde(default = "default_last_seen")]
257 pub last_seen_at: u64,
258
259 // ── Plugin-host extensions (operator brokerage of custom + third-
260 // party MCPs). All optional; default-None preserves the legacy
261 // remote-Streamable-HTTP behavior for existing entries.
262
263 /// Upstream credential injection for this tool. When `Some`, the
264 /// operator's sealed secret (looked up by `sealed_secret_ref` at
265 /// invocation time) is injected per the variant rules — into a
266 /// request header for `Mcp` / `McpSse` / `Api`, into an env var
267 /// for `McpStdio`. Tenants NEVER see the underlying secret.
268 /// Default `None` means no credentials are injected (public MCPs).
269 #[serde(default)]
270 pub upstream_auth: Option<UpstreamAuth>,
271
272 /// Subprocess spawn specification for `McpStdio` transport.
273 /// Required when `tool_type == "mcp-stdio"`. Ignored for other
274 /// transports.
275 #[serde(default)]
276 pub spawn_spec: Option<StdioSpawnSpec>,
277
278 /// Optional subject-level access list. When `Some(vec)`, only
279 /// API-key subjects whose `subject` appears in `vec` are permitted
280 /// to invoke this tool. `None` (the default) means the tool is
281 /// open to any API key that is allowed by `AgentDelegation`.
282 #[serde(default)]
283 pub allowed_to_subjects: Option<Vec<String>>,
284}
285
286fn default_last_seen() -> u64 {
287 std::time::SystemTime::now()
288 .duration_since(std::time::UNIX_EPOCH)
289 .unwrap_or_default()
290 .as_secs()
291}
292
293impl ToolDefinition {
294 /// Creates a new tool definition with default values
295 pub fn new(
296 name: String,
297 version: String,
298 tool_type: String,
299 endpoint: String,
300 description: String,
301 category: String,
302 ) -> Self {
303 let tool_id = uuid::Uuid::new_v4().to_string();
304 let created_at = std::time::SystemTime::now()
305 .duration_since(std::time::UNIX_EPOCH)
306 .unwrap_or_default()
307 .as_secs();
308
309 Self {
310 tool_id,
311 name,
312 version,
313 tool_type,
314 endpoint,
315 description,
316 capabilities: Vec::new(),
317 category,
318 creator_did: None,
319 creator_wallet: None,
320 price_per_call: 0,
321 status: ToolStatus::Active,
322 created_at,
323 invocation_count: 0,
324 last_seen_at: created_at,
325 upstream_auth: None,
326 spawn_spec: None,
327 allowed_to_subjects: None,
328 }
329 }
330
331 /// Returns the typed transport mode derived from the legacy
332 /// `tool_type` string. Returns `None` for unknown strings so the
333 /// caller can refuse to invoke an unknown transport safely.
334 pub fn transport_mode(&self) -> Option<ToolTransportMode> {
335 ToolTransportMode::parse_str(&self.tool_type)
336 }
337
338 /// Sets both the typed transport mode and the legacy `tool_type`
339 /// string field in lockstep. Use this from the registration RPC
340 /// when the caller passes a strongly-typed transport mode.
341 pub fn set_transport_mode(&mut self, mode: ToolTransportMode) {
342 self.tool_type = mode.as_str().to_string();
343 }
344
345 /// Returns `true` when `subject` is permitted to invoke this tool
346 /// per the optional subject-level access list. When the list is
347 /// `None`, the tool is open to any API key (subject-gating is
348 /// disabled). When `Some(vec)`, only subjects in `vec` are allowed.
349 pub fn is_subject_allowed(&self, subject: Option<&str>) -> bool {
350 match (&self.allowed_to_subjects, subject) {
351 (None, _) => true,
352 (Some(list), Some(s)) => list.iter().any(|x| x == s),
353 (Some(_), None) => false,
354 }
355 }
356
357 /// Returns `true` when this tool is paid (non-zero `price_per_call`).
358 pub fn is_paid(&self) -> bool {
359 self.price_per_call > 0
360 }
361
362 /// Validate registration invariants. Any paid tool must declare a
363 /// `creator_wallet` to receive the creator share of each invocation.
364 /// Free tools (`price_per_call == 0`) may omit `creator_wallet`.
365 pub fn validate_for_registration(&self) -> Result<(), &'static str> {
366 if self.is_paid() && self.creator_wallet.is_none() {
367 return Err("Paid tool (price_per_call > 0) requires a creator_wallet");
368 }
369 Ok(())
370 }
371
372 /// Returns true if the tool is available for invocation
373 pub fn is_available(&self) -> bool {
374 self.status == ToolStatus::Active
375 }
376
377 /// Bumps `last_seen_at` to current wall-clock time.
378 pub fn touch(&mut self) {
379 self.last_seen_at = default_last_seen();
380 }
381}
382
383/// Filter parameters for listing and searching tools
384#[derive(Debug, Clone, Default, Serialize, Deserialize)]
385pub struct ToolFilter {
386 /// Filter by tool type ("mcp", "api", "native")
387 pub tool_type: Option<String>,
388
389 /// Filter by category
390 pub category: Option<String>,
391
392 /// Filter by status ("active", "inactive")
393 pub status: Option<String>,
394
395 /// Filter by creator DID
396 pub creator_did: Option<String>,
397
398 /// Free-text search in name, description, and capabilities
399 pub query: Option<String>,
400
401 /// Maximum number of results to return
402 pub limit: Option<usize>,
403
404 /// Pagination offset
405 pub offset: Option<usize>,
406}
407
408/// Result of invoking a tool
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct ToolInvocationResult {
411 /// The tool that was invoked
412 pub tool_id: String,
413
414 /// Invocation identifier for tracking
415 pub invocation_id: String,
416
417 /// The output payload returned by the tool
418 pub output: serde_json::Value,
419
420 /// Settlement transaction hash, if a payment was made. `None` for
421 /// free tools or for the in-process token transfer path (which
422 /// settles via the live `TnzoToken` ledger rather than a discrete
423 /// chain transaction).
424 pub settlement_tx: Option<String>,
425
426 /// Amount paid by the invoker in atto-TNZO. `0` for free tools.
427 pub amount_paid: u128,
428
429 /// Unix timestamp when the invocation completed
430 pub completed_at: u64,
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn test_tool_definition_new() {
439 let tool = ToolDefinition::new(
440 "web-search".to_string(),
441 "1.0.0".to_string(),
442 "mcp".to_string(),
443 "http://localhost:3001/mcp".to_string(),
444 "MCP server providing web search capability".to_string(),
445 "search".to_string(),
446 );
447
448 assert!(!tool.tool_id.is_empty());
449 assert_eq!(tool.name, "web-search");
450 assert_eq!(tool.version, "1.0.0");
451 assert_eq!(tool.tool_type, "mcp");
452 assert!(tool.is_available());
453 assert_eq!(tool.price_per_call, 0);
454 assert!(!tool.is_paid());
455 assert!(tool.creator_wallet.is_none());
456 assert_eq!(tool.status, ToolStatus::Active);
457 assert_eq!(tool.invocation_count, 0);
458 }
459
460 #[test]
461 fn paid_tool_without_wallet_fails_validation() {
462 let mut tool = ToolDefinition::new(
463 "premium-mcp".to_string(),
464 "1.0.0".to_string(),
465 "mcp".to_string(),
466 "https://example.com/mcp".to_string(),
467 "Paid MCP server".to_string(),
468 "data".to_string(),
469 );
470 tool.price_per_call = 1_000;
471 assert!(tool.validate_for_registration().is_err());
472 }
473
474 #[test]
475 fn free_tool_without_wallet_passes_validation() {
476 let tool = ToolDefinition::new(
477 "free-mcp".to_string(),
478 "1.0.0".to_string(),
479 "mcp".to_string(),
480 "https://example.com/mcp".to_string(),
481 "Free MCP server".to_string(),
482 "data".to_string(),
483 );
484 assert!(tool.validate_for_registration().is_ok());
485 }
486
487 #[test]
488 fn paid_tool_with_wallet_passes_validation() {
489 let mut tool = ToolDefinition::new(
490 "paid-mcp".to_string(),
491 "1.0.0".to_string(),
492 "mcp".to_string(),
493 "https://example.com/mcp".to_string(),
494 "Paid MCP server".to_string(),
495 "data".to_string(),
496 );
497 tool.price_per_call = 1_000;
498 tool.creator_wallet = Some(Address::default());
499 assert!(tool.validate_for_registration().is_ok());
500 }
501
502 #[test]
503 fn test_tool_status_default() {
504 assert_eq!(ToolStatus::default(), ToolStatus::Active);
505 }
506
507 #[test]
508 fn test_tool_filter_default() {
509 let filter = ToolFilter::default();
510 assert!(filter.tool_type.is_none());
511 assert!(filter.category.is_none());
512 assert!(filter.status.is_none());
513 assert!(filter.query.is_none());
514 }
515
516 #[test]
517 fn test_tool_serialization() {
518 let mut tool = ToolDefinition::new(
519 "code-executor".to_string(),
520 "2.0.0".to_string(),
521 "mcp".to_string(),
522 "https://tools.tenzro.network/code-executor/mcp".to_string(),
523 "Executes code in sandboxed environments".to_string(),
524 "code".to_string(),
525 );
526 tool.capabilities = vec!["python".to_string(), "javascript".to_string()];
527 tool.creator_did = Some("did:tenzro:human:test-123".to_string());
528
529 let json = serde_json::to_string(&tool).unwrap();
530 let deserialized: ToolDefinition = serde_json::from_str(&json).unwrap();
531 assert_eq!(tool.tool_id, deserialized.tool_id);
532 assert_eq!(tool.name, deserialized.name);
533 assert_eq!(tool.capabilities.len(), 2);
534 }
535}