Skip to main content

runx_contracts/
tools.rs

1//! Contract types for tool manifests and tool catalog JSON surfaces.
2// rust-style-allow: large-file - tool catalog contracts keep serde parity shapes together.
3
4use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8
9use crate::JsonNumber;
10use crate::schema::RunxSchema;
11
12pub const TOOL_MANIFEST_SCHEMA: &str = "runx.tool.manifest.v1";
13pub const TOOL_BUILD_REPORT_SCHEMA: &str = "runx.tool.build.v1";
14
15pub type JsonPayloadObject = BTreeMap<String, JsonPayload>;
16
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
18#[serde(untagged)]
19pub enum JsonPayload {
20    Null,
21    Bool(bool),
22    Number(JsonNumber),
23    String(String),
24    Array(Vec<JsonPayload>),
25    Object(JsonPayloadObject),
26}
27
28impl RunxSchema for JsonPayload {
29    fn json_schema() -> Value {
30        json!({})
31    }
32}
33
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
35pub enum ToolManifestSchema {
36    #[serde(rename = "runx.tool.manifest.v1")]
37    V1,
38}
39
40#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
41pub enum ToolBuildReportSchema {
42    #[serde(rename = "runx.tool.build.v1")]
43    V1,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
47#[serde(rename_all = "snake_case")]
48pub enum ToolCommandInputMode {
49    Args,
50    Stdin,
51    None,
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
55#[serde(rename_all = "kebab-case")]
56pub enum ToolSourceType {
57    CliTool,
58    Mcp,
59    A2a,
60    Catalog,
61    Http,
62}
63
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
65#[serde(rename_all = "snake_case")]
66pub enum ToolBuildStatus {
67    Success,
68    Failure,
69}
70
71#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
72#[serde(rename_all = "snake_case")]
73pub enum ToolInspectOrigin {
74    Local,
75    Imported,
76}
77
78#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, RunxSchema)]
79#[serde(deny_unknown_fields)]
80#[runx_schema(id = "runx.tool.manifest.v1")]
81pub struct ToolManifest {
82    pub schema: ToolManifestSchema,
83    pub name: String,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub version: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub description: Option<String>,
88    pub source: ToolSource,
89    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90    pub inputs: BTreeMap<String, ToolInput>,
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub scopes: Vec<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub risk: Option<JsonPayload>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub runx: Option<JsonPayloadObject>,
97    pub runtime: RuntimeCommand,
98    pub output: ToolOutput,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub retry: Option<ToolRetryPolicy>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub idempotency: Option<ToolIdempotencyPolicy>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub mutating: Option<bool>,
105    pub source_hash: String,
106    pub schema_hash: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub toolkit_version: Option<String>,
109}
110
111#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, RunxSchema)]
112#[serde(deny_unknown_fields)]
113pub struct ToolInput {
114    #[serde(rename = "type")]
115    pub input_type: String,
116    pub required: bool,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub description: Option<String>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub default: Option<JsonPayload>,
121    /// Marks this input as a structured artifact packet (rather than a scalar
122    /// or free-form blob). Consumers that fanout/dedupe on artifact identity
123    /// honour this flag.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub artifact: Option<bool>,
126}
127
128#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, RunxSchema)]
129#[serde(deny_unknown_fields)]
130pub struct ToolOutput {
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub packet: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub wrap_as: Option<String>,
135    /// Map of named-emit label → output key, when this tool fans out to
136    /// multiple distinct artifact streams. Each label points at an entry in
137    /// `outputs`.
138    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
139    pub named_emits: BTreeMap<String, String>,
140    /// Per-output packet bindings keyed by output name. Populated alongside
141    /// `named_emits` when a tool emits more than one packet.
142    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
143    pub outputs: BTreeMap<String, ToolOutputBinding>,
144    #[serde(flatten)]
145    pub extra: JsonPayloadObject,
146}
147
148#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, RunxSchema)]
149#[serde(deny_unknown_fields)]
150pub struct ToolOutputBinding {
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub packet: Option<String>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub wrap_as: Option<String>,
155    #[serde(flatten)]
156    pub extra: JsonPayloadObject,
157}
158
159#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, RunxSchema)]
160#[serde(deny_unknown_fields)]
161pub struct ToolSource {
162    #[serde(rename = "type")]
163    pub source_type: ToolSourceType,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub command: Option<String>,
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub args: Vec<String>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub cwd: Option<String>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub input_mode: Option<ToolCommandInputMode>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub timeout_seconds: Option<u64>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub sandbox: Option<ToolSandbox>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub server: Option<ToolMcpServer>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub catalog_ref: Option<String>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub tool: Option<String>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub arguments: Option<JsonPayloadObject>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub agent_card_url: Option<String>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub agent_identity: Option<String>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub http: Option<ToolHttpSource>,
190}
191
192/// Config for an `http` tool source: a governed HTTP call. Mirrors the parser's
193/// `SkillHttpSource`. Header values may carry `${secret:NAME}` references resolved
194/// at invocation; `allow_private_network` is the explicit, default-off opt-in to
195/// reach private/loopback endpoints (the governed transport blocks them otherwise).
196#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
197#[serde(deny_unknown_fields)]
198pub struct ToolHttpSource {
199    pub url: String,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub method: Option<String>,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub headers: Option<BTreeMap<String, String>>,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub allow_private_network: Option<bool>,
206}
207
208#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
209#[serde(deny_unknown_fields)]
210pub struct ToolMcpServer {
211    pub command: String,
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    pub args: Vec<String>,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub cwd: Option<String>,
216}
217
218#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
219#[serde(deny_unknown_fields)]
220pub struct RuntimeCommand {
221    pub command: String,
222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
223    pub args: Vec<String>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub cwd: Option<String>,
226    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
227    pub env: BTreeMap<String, String>,
228}
229
230#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
231#[serde(rename_all = "kebab-case")]
232pub enum ToolSandboxProfile {
233    Readonly,
234    WorkspaceWrite,
235    Network,
236    UnrestrictedLocalDev,
237}
238
239#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
240#[serde(rename_all = "kebab-case")]
241pub enum ToolSandboxCwdPolicy {
242    SkillDirectory,
243    Workspace,
244    Custom,
245}
246
247#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
248#[serde(deny_unknown_fields)]
249pub struct ToolSandbox {
250    pub profile: ToolSandboxProfile,
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub cwd_policy: Option<ToolSandboxCwdPolicy>,
253    #[serde(default, skip_serializing_if = "Vec::is_empty")]
254    pub env_allowlist: Vec<String>,
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub network: Option<bool>,
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub writable_paths: Vec<String>,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub require_enforcement: Option<bool>,
261}
262
263#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
264#[serde(deny_unknown_fields)]
265pub struct ToolRetryPolicy {
266    pub max_attempts: u64,
267}
268
269#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
270#[serde(deny_unknown_fields)]
271pub struct ToolIdempotencyPolicy {
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub key: Option<String>,
274}
275
276#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct BuiltToolItem {
279    pub path: String,
280    pub manifest: String,
281    pub source_hash: String,
282    pub schema_hash: String,
283}
284
285#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(deny_unknown_fields)]
287pub struct ToolBuildReport {
288    pub schema: ToolBuildReportSchema,
289    pub status: ToolBuildStatus,
290    pub built: Vec<BuiltToolItem>,
291    pub errors: Vec<String>,
292}
293
294#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(deny_unknown_fields)]
296pub struct ToolCatalogSearchOptions {
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub limit: Option<u64>,
299}
300
301#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(deny_unknown_fields)]
303pub struct ToolCatalogSearchResult {
304    pub tool_id: String,
305    pub name: String,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub summary: Option<String>,
308    pub source: String,
309    pub source_label: String,
310    pub source_type: String,
311    pub namespace: String,
312    pub external_name: String,
313    pub required_scopes: Vec<String>,
314    pub tags: Vec<String>,
315    pub catalog_ref: String,
316}
317
318#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(deny_unknown_fields)]
320pub struct ToolCatalogSearchReport {
321    pub status: ToolBuildStatus,
322    pub query: String,
323    pub source: String,
324    pub results: Vec<ToolCatalogSearchResult>,
325}
326
327#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct ToolInspectResult {
330    #[serde(rename = "ref")]
331    pub tool_ref: String,
332    pub name: String,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub description: Option<String>,
335    pub execution_source_type: String,
336    pub inputs: BTreeMap<String, ToolInput>,
337    pub scopes: Vec<String>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub mutating: Option<bool>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub runtime: Option<RuntimeCommand>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub risk: Option<JsonPayload>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub runx: Option<ToolInspectRunx>,
346    pub reference_path: String,
347    pub skill_directory: String,
348    pub provenance: ToolInspectProvenance,
349}
350
351#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
352#[serde(deny_unknown_fields)]
353pub struct ToolInspectReport {
354    pub status: ToolBuildStatus,
355    pub tool: ToolInspectResult,
356}
357
358#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
359#[serde(untagged)]
360pub enum ToolInspectRunx {
361    Imported {
362        imported_from: ToolInspectImportedFrom,
363    },
364    Object(JsonPayloadObject),
365}
366
367#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
368#[serde(deny_unknown_fields)]
369pub struct ToolInspectImportedFrom {
370    pub source: String,
371    pub source_label: String,
372    pub source_type: String,
373    pub namespace: String,
374    pub external_name: String,
375    pub digest: String,
376}
377
378#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(deny_unknown_fields)]
380pub struct ToolInspectOptions {
381    #[serde(rename = "ref")]
382    pub tool_ref: String,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub source: Option<String>,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub search_from_directory: Option<String>,
387}
388
389#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(deny_unknown_fields)]
391pub struct ToolInspectProvenance {
392    pub origin: ToolInspectOrigin,
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub source: Option<String>,
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub source_label: Option<String>,
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub source_type: Option<String>,
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub namespace: Option<String>,
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub external_name: Option<String>,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub catalog_ref: Option<String>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub tool_id: Option<String>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub tags: Option<Vec<String>>,
409}
410
411#[cfg(test)]
412mod tests {
413    use std::collections::BTreeMap;
414
415    use super::{
416        RuntimeCommand, ToolBuildReport, ToolBuildReportSchema, ToolBuildStatus,
417        ToolCatalogSearchResult, ToolCommandInputMode, ToolInput, ToolInspectOrigin,
418        ToolInspectProvenance, ToolInspectResult, ToolManifest, ToolManifestSchema, ToolOutput,
419        ToolSource, ToolSourceType,
420    };
421
422    #[test]
423    fn tool_manifest_round_trips_snake_case_fields() -> Result<(), serde_json::Error> {
424        let json = r#"{
425          "schema": "runx.tool.manifest.v1",
426          "name": "fs.read",
427          "description": "Read a UTF-8 text file.",
428          "source": {
429            "type": "cli-tool",
430            "command": "node",
431            "args": ["./run.mjs"],
432            "timeout_seconds": 30,
433            "input_mode": "stdin"
434          },
435          "inputs": {
436            "path": {
437              "type": "string",
438              "required": true,
439              "description": "Path to read."
440            }
441          },
442          "output": {
443            "packet": "runx.fs.file_read.v1",
444            "wrap_as": "file_read"
445          },
446          "scopes": ["fs.read"],
447          "runtime": {
448            "command": "node",
449            "args": ["./run.mjs"]
450          },
451          "source_hash": "sha256:source",
452          "schema_hash": "sha256:schema",
453          "toolkit_version": "0.1.4"
454        }"#;
455
456        let manifest: ToolManifest = serde_json::from_str(json)?;
457
458        assert_eq!(manifest.schema, ToolManifestSchema::V1);
459        assert_eq!(manifest.source.source_type, ToolSourceType::CliTool);
460        assert_eq!(
461            manifest.source.input_mode,
462            Some(ToolCommandInputMode::Stdin)
463        );
464        assert_eq!(manifest.output.wrap_as.as_deref(), Some("file_read"));
465
466        let encoded = serde_json::to_value(&manifest)?;
467        assert_eq!(encoded["source"]["timeout_seconds"], 30);
468        assert_eq!(encoded["runtime"]["args"][0], "./run.mjs");
469        assert!(encoded.get("risk").is_none());
470        Ok(())
471    }
472
473    #[test]
474    fn tool_optional_manifest_fields_are_omitted() -> Result<(), serde_json::Error> {
475        let encoded = serde_json::to_value(catalog_tool_manifest_fixture())?;
476
477        assert!(encoded.get("description").is_none());
478        assert!(encoded["source"].get("args").is_none());
479        assert!(encoded["runtime"].get("env").is_none());
480        assert!(encoded.get("toolkit_version").is_none());
481        Ok(())
482    }
483
484    fn catalog_tool_manifest_fixture() -> ToolManifest {
485        ToolManifest {
486            schema: ToolManifestSchema::V1,
487            name: "fixture.echo".to_owned(),
488            version: None,
489            description: None,
490            source: catalog_tool_source_fixture(),
491            runtime: RuntimeCommand {
492                command: "node".to_owned(),
493                args: Vec::new(),
494                cwd: None,
495                env: Default::default(),
496            },
497            inputs: [(
498                "message".to_owned(),
499                ToolInput {
500                    input_type: "string".to_owned(),
501                    required: true,
502                    description: None,
503                    default: None,
504                    artifact: None,
505                },
506            )]
507            .into_iter()
508            .collect(),
509            output: ToolOutput {
510                packet: None,
511                wrap_as: None,
512                named_emits: BTreeMap::new(),
513                outputs: BTreeMap::new(),
514                extra: Default::default(),
515            },
516            scopes: Vec::new(),
517            risk: None,
518            retry: None,
519            idempotency: None,
520            mutating: None,
521            runx: None,
522            source_hash: "sha256:source".to_owned(),
523            schema_hash: "sha256:schema".to_owned(),
524            toolkit_version: None,
525        }
526    }
527
528    fn catalog_tool_source_fixture() -> ToolSource {
529        ToolSource {
530            source_type: ToolSourceType::Catalog,
531            command: None,
532            args: Vec::new(),
533            cwd: None,
534            timeout_seconds: None,
535            input_mode: None,
536            sandbox: None,
537            server: None,
538            catalog_ref: Some("fixture-mcp:fixture.echo".to_owned()),
539            tool: None,
540            arguments: None,
541            agent_card_url: None,
542            agent_identity: None,
543            http: None,
544        }
545    }
546
547    #[test]
548    fn tool_build_report_uses_cli_json_shape() -> Result<(), serde_json::Error> {
549        let report: ToolBuildReport = serde_json::from_str(
550            r#"{
551              "schema": "runx.tool.build.v1",
552              "status": "success",
553              "built": [{
554                "path": "tools/demo/echo",
555                "manifest": "tools/demo/echo/manifest.json",
556                "source_hash": "sha256:source",
557                "schema_hash": "sha256:schema"
558              }],
559              "errors": []
560            }"#,
561        )?;
562
563        assert_eq!(report.schema, ToolBuildReportSchema::V1);
564        assert_eq!(report.status, ToolBuildStatus::Success);
565        assert_eq!(report.built[0].manifest, "tools/demo/echo/manifest.json");
566        Ok(())
567    }
568
569    #[test]
570    fn tool_catalog_search_result_uses_executor_json_shape() -> Result<(), serde_json::Error> {
571        let result: ToolCatalogSearchResult = serde_json::from_str(
572            r#"{
573              "tool_id": "fixture-mcp/fixture.echo",
574              "name": "fixture.echo",
575              "summary": "Echo a message.",
576              "source": "fixture-mcp",
577              "source_label": "Fixture MCP",
578              "source_type": "mcp",
579              "namespace": "fixture",
580              "external_name": "echo",
581              "required_scopes": ["fixture.echo"],
582              "tags": ["mcp"],
583              "catalog_ref": "fixture-mcp:fixture.echo"
584            }"#,
585        )?;
586
587        assert_eq!(result.catalog_ref, "fixture-mcp:fixture.echo");
588        assert_eq!(result.required_scopes, ["fixture.echo"]);
589        Ok(())
590    }
591
592    #[test]
593    fn tool_inspect_result_uses_provenance_shape() -> Result<(), serde_json::Error> {
594        let result: ToolInspectResult = serde_json::from_str(
595            r#"{
596              "ref": "fixture.echo",
597              "name": "fixture.echo",
598              "execution_source_type": "catalog",
599              "inputs": {},
600              "scopes": ["fixture.echo"],
601              "reference_path": "catalog:fixture-mcp:fixture.echo",
602              "skill_directory": ".",
603              "provenance": {
604                "origin": "imported",
605                "source": "fixture-mcp",
606                "source_label": "Fixture MCP",
607                "source_type": "mcp",
608                "namespace": "fixture",
609                "external_name": "echo",
610                "catalog_ref": "fixture-mcp:fixture.echo",
611                "tool_id": "fixture-mcp/fixture.echo",
612                "tags": ["mcp"]
613              }
614            }"#,
615        )?;
616
617        assert_eq!(result.tool_ref, "fixture.echo");
618        assert_eq!(
619            result.provenance,
620            ToolInspectProvenance {
621                origin: ToolInspectOrigin::Imported,
622                source: Some("fixture-mcp".to_owned()),
623                source_label: Some("Fixture MCP".to_owned()),
624                source_type: Some("mcp".to_owned()),
625                namespace: Some("fixture".to_owned()),
626                external_name: Some("echo".to_owned()),
627                catalog_ref: Some("fixture-mcp:fixture.echo".to_owned()),
628                tool_id: Some("fixture-mcp/fixture.echo".to_owned()),
629                tags: Some(vec!["mcp".to_owned()]),
630            }
631        );
632        Ok(())
633    }
634}