1use 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 #[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 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
139 pub named_emits: BTreeMap<String, String>,
140 #[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#[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}