Skip to main content

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}