Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/thulp-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "thulp-core"
version = "0.3.1"
version = "0.3.2"
edition = "2021"
authors = ["Dirmacs <contact@dirmacs.org>"]
license = "MIT OR Apache-2.0"
Expand Down
108 changes: 108 additions & 0 deletions crates/thulp-core/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value> = 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) = &param.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<Vec<Parameter>> {
let mut params = Vec::new();
Expand Down Expand Up @@ -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());
}
}
39 changes: 37 additions & 2 deletions crates/thulp-registry/README.md
Original file line number Diff line number Diff line change
@@ -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<String, Arc<dyn Tool>>` where `Tool` has
`async fn execute(&self, args: Value) -> Result<Value>`. 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<dyn Tool>` 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

Expand Down
27 changes: 24 additions & 3 deletions crates/thulp-registry/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<String, Arc<dyn Tool>>` where `Tool`
//! has an `async fn execute(&self, args: Value) -> Result<Value>`. 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;
Expand Down
Loading