From c4ef2201e7886b8c3eb84fbfae962eb72a59feb5 Mon Sep 17 00:00:00 2001 From: bkataru Date: Sat, 11 Apr 2026 20:10:34 +0200 Subject: [PATCH 1/3] chore(thulp): sync 3 file(s) --- crates/thulp-core/src/tool.rs | 108 +++++++++++++++++++++++++++++++ crates/thulp-registry/README.md | 39 ++++++++++- crates/thulp-registry/src/lib.rs | 27 +++++++- 3 files changed, 169 insertions(+), 5 deletions(-) diff --git a/crates/thulp-core/src/tool.rs b/crates/thulp-core/src/tool.rs index 3c64b6a..1e30157 100644 --- a/crates/thulp-core/src/tool.rs +++ b/crates/thulp-core/src/tool.rs @@ -83,6 +83,53 @@ impl ToolDefinition { Ok(()) } + /// Convert this tool definition into an MCP-compatible JSON Schema `Value` + /// suitable for the `tools[].function.parameters` field that + /// OpenAI-compatible LLM APIs expect. + /// + /// Inverse of `parse_mcp_input_schema`. Round-trip is structurally stable + /// for `name`, `param_type`, `required`, `description`, `default`, and + /// `enum_values`. Round-trip is exact when no extra schema fields are + /// present. + pub fn to_mcp_input_schema(&self) -> serde_json::Value { + let mut properties = serde_json::Map::new(); + let mut required: Vec = Vec::new(); + + for param in &self.parameters { + let mut prop = serde_json::Map::new(); + prop.insert( + "type".to_string(), + serde_json::Value::String(param.param_type.as_str().to_string()), + ); + if !param.description.is_empty() { + prop.insert( + "description".to_string(), + serde_json::Value::String(param.description.clone()), + ); + } + if !param.enum_values.is_empty() { + prop.insert( + "enum".to_string(), + serde_json::Value::Array(param.enum_values.clone()), + ); + } + if let Some(default) = ¶m.default { + prop.insert("default".to_string(), default.clone()); + } + properties.insert(param.name.clone(), serde_json::Value::Object(prop)); + + if param.required { + required.push(serde_json::Value::String(param.name.clone())); + } + } + + serde_json::json!({ + "type": "object", + "properties": properties, + "required": required, + }) + } + /// Parse MCP inputSchema into Parameters pub fn parse_mcp_input_schema(schema: &serde_json::Value) -> Result> { let mut params = Vec::new(); @@ -834,4 +881,65 @@ mod tests { assert_eq!(params.len(), 1); assert_eq!(params[0].description, ""); } + + #[test] + fn to_mcp_input_schema_basic() { + let def = ToolDefinition::builder("test_tool") + .description("A test tool") + .parameter(Parameter::required_string("path")) + .parameter(Parameter::optional_string("encoding")) + .build(); + let schema = def.to_mcp_input_schema(); + assert_eq!(schema["type"], "object"); + assert_eq!(schema["properties"]["path"]["type"], "string"); + assert_eq!(schema["properties"]["encoding"]["type"], "string"); + let required = schema["required"].as_array().expect("required is array"); + assert_eq!(required.len(), 1); + assert_eq!(required[0], "path"); + } + + #[test] + fn to_mcp_input_schema_round_trip() { + let original = ToolDefinition::builder("rt") + .description("round trip") + .parameter(Parameter::required_string("name")) + .parameter(Parameter::optional_string("note")) + .build(); + let schema = original.to_mcp_input_schema(); + let parsed = ToolDefinition::parse_mcp_input_schema(&schema).unwrap(); + assert_eq!(parsed.len(), original.parameters.len()); + for orig in &original.parameters { + let p = parsed + .iter() + .find(|p| p.name == orig.name) + .unwrap_or_else(|| panic!("missing param {}", orig.name)); + assert_eq!(p.param_type, orig.param_type); + assert_eq!(p.required, orig.required); + } + } + + #[test] + fn to_mcp_input_schema_carries_enum_and_default() { + let mut param = Parameter::new("level"); + param.param_type = ParameterType::String; + param.required = false; + param.enum_values = vec![json!("low"), json!("med"), json!("high")]; + param.default = Some(json!("med")); + let def = ToolDefinition::builder("with_enum").parameter(param).build(); + + let schema = def.to_mcp_input_schema(); + let level = &schema["properties"]["level"]; + assert_eq!(level["type"], "string"); + assert_eq!(level["enum"][0], "low"); + assert_eq!(level["default"], "med"); + } + + #[test] + fn to_mcp_input_schema_empty_definition_yields_empty_properties() { + let def = ToolDefinition::new("noargs"); + let schema = def.to_mcp_input_schema(); + assert_eq!(schema["type"], "object"); + assert!(schema["properties"].as_object().unwrap().is_empty()); + assert!(schema["required"].as_array().unwrap().is_empty()); + } } diff --git a/crates/thulp-registry/README.md b/crates/thulp-registry/README.md index 3a30d0a..1333d4e 100644 --- a/crates/thulp-registry/README.md +++ b/crates/thulp-registry/README.md @@ -1,10 +1,45 @@ # thulp-registry -Async thread-safe tool registry for Thulp. +Async thread-safe **metadata catalog** for `thulp_core::ToolDefinition`. ## Overview -This crate provides a registry for managing tool definitions with support for dynamic registration, tagging, and discovery. The registry is designed for concurrent access in async environments. +This crate provides a registry for managing tool *definitions* (the metadata +the LLM sees) with support for dynamic registration, tagging, and discovery. +The registry is designed for concurrent access in async environments. + +## Intended Use + +`thulp-registry` is a metadata-only store. It holds `ToolDefinition` values — +the JSON-schema-shaped descriptions an LLM consumes — plus tags for grouping +and discovery. It is **not** an execution runtime: there is no `Tool` trait +and no `execute()` method. + +Use this crate when you need to: + +- Publish or serialize a catalog of tools (MCP discovery, skill manifests, + documentation generation) +- Filter or tag definitions before exposing them to an LLM +- Maintain a cross-process / cross-service tool catalog where the actual + executors live elsewhere (e.g., in a remote MCP server) + +If instead you need an **in-process executable registry** that can dispatch +`(name, args)` to a Rust implementation, you want a different abstraction: +typically a `HashMap>` where `Tool` has +`async fn execute(&self, args: Value) -> Result`. Two existing +examples in the dirmacs stack: + +- `pawan::tools::ToolRegistry` — pawan's in-process executable registry + with 3-tier visibility (Core / Standard / Extended) and scored + `select_for_query()` for dynamic tool selection +- `ares::tools::registry::ToolRegistry` — ares-server's executable registry + used by the agent loop + +Both wrappers keep their own `Arc` storage for execution and use +`thulp-core::ToolDefinition` for the metadata side. They also integrate +`thulp-query` for DSL-driven filtering. The split is intentional: separating +metadata from execution lets the same definitions be published, queried, and +shipped to LLMs without dragging an execution runtime into every consumer. ## Features diff --git a/crates/thulp-registry/src/lib.rs b/crates/thulp-registry/src/lib.rs index 3c43067..4b18bd0 100644 --- a/crates/thulp-registry/src/lib.rs +++ b/crates/thulp-registry/src/lib.rs @@ -1,9 +1,30 @@ //! # thulp-registry //! -//! Tool registry implementation for thulp. +//! **Metadata catalog** for `thulp_core::ToolDefinition`, with tag-based +//! discovery and async-safe concurrent access. //! -//! This crate provides a registry for managing tool definitions and configurations, -//! including loading from configuration files, caching, and discovery. +//! ## Scope +//! +//! This crate stores tool *metadata* (`ToolDefinition`) — not executable +//! handles. Use it for: +//! +//! - Publishing/serializing a catalog of tools (e.g., for MCP discovery, +//! skill manifests, documentation generation) +//! - Tag-based filtering of definitions before exposing to an LLM +//! - Cross-process / cross-service tool catalogs where the executor lives +//! somewhere else +//! +//! It is intentionally *not* an execution runtime. There is no `Tool` trait +//! and no `execute()` method here. If you need an in-process registry that +//! can dispatch `args` to a tool implementation, you want a different +//! abstraction — typically a `HashMap>` where `Tool` +//! has an `async fn execute(&self, args: Value) -> Result`. Examples +//! of that pattern in the dirmacs stack: `pawan::tools::ToolRegistry` and +//! `ares::tools::registry::ToolRegistry`. Both keep their own executable +//! registries and use `thulp-core::ToolDefinition` (and `thulp-query` for +//! filtering) for the metadata side. +//! +//! See `README.md` "Intended Use" for the full rationale. use std::collections::HashMap; use std::sync::Arc; From a3e3482d65af63c12f2a6f0a48799a074fdbc0d7 Mon Sep 17 00:00:00 2001 From: bkataru Date: Sat, 11 Apr 2026 20:17:43 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(thulp-core):=20add=20ToolDefinition::t?= =?UTF-8?q?o=5Fmcp=5Finput=5Fschema()=20=E2=80=94=20bump=200.3.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inverse of parse_mcp_input_schema(). Converts the typed Vec into an MCP/OpenAI-compatible JSON Schema Value, suitable for the tools[].function.parameters field that LLM APIs expect. Round-trip stable for name, type, required, description, default, enum_values. +4 unit tests covering basic, round-trip, enum/default carry, and empty-definition cases. Also: clarify thulp-registry positioning as a metadata-only catalog (not an execution runtime). Pawan and ares keep their own executable ToolRegistry; thulp-registry is for serializable tool catalogs. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/thulp-core/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/thulp-core/Cargo.toml b/crates/thulp-core/Cargo.toml index 2c7c404..07eeae8 100644 --- a/crates/thulp-core/Cargo.toml +++ b/crates/thulp-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thulp-core" -version = "0.3.1" +version = "0.3.2" edition = "2021" authors = ["Dirmacs "] license = "MIT OR Apache-2.0" From 6136f5682bc631944a4c992d16f553b2341ed40c Mon Sep 17 00:00:00 2001 From: bkataru Date: Sat, 11 Apr 2026 21:58:35 +0200 Subject: [PATCH 3/3] chore: update Cargo.lock for thulp-core 0.3.2 bump --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index cab65d4..3c8d455 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5188,7 +5188,7 @@ dependencies = [ [[package]] name = "thulp-core" -version = "0.3.1" +version = "0.3.2" dependencies = [ "async-trait", "criterion",