Overview
HandsAI’s Dynamic Tool Registry is the core system that allows you to add any REST API as an MCP-discoverable tool without writing code. Tools are stored in SQLite, validated for security, and cached in memory for fast access.
There are two primary ways to register tools in HandsAI:
1. UI-Based Registration
The HandsAI frontend (Angular) provides a visual interface to:
Create API Providers (base URLs with authentication)
Add individual tools with endpoints and parameters
Test tools before enabling them
Enable/disable tools on the fly
Changes made in the UI are persisted to the SQLite database and immediately reflected in the tool cache.
2. JSON Import
You can bulk-import tools using JSON files. HandsAI includes several pre-configured use cases in /docs/casos-de-uso/.
Example: Resend Email API
{
"name" : "Resend API" ,
"code" : "resend" ,
"baseUrl" : "https://api.resend.com" ,
"authenticationType" : "BEARER_TOKEN" ,
"apiKeyLocation" : "HEADER" ,
"apiKeyName" : "Authorization" ,
"apiKeyValue" : "<YOUR_API_KEY>" ,
"customHeaders" : {
"Content-Type" : "application/json" ,
"User-Agent" : "HandsAI/1.0"
},
"tools" : [
{
"name" : "Enviar Email (Resend)" ,
"code" : "resend-send-email" ,
"description" : "Envía un correo electrónico usando la API de Resend." ,
"endpointPath" : "/emails" ,
"httpMethod" : "POST" ,
"enabled" : true ,
"isExportable" : true ,
"parameters" : [
{
"name" : "from" ,
"type" : "STRING" ,
"description" : "Dirección de envío (ej. Acme <[email protected] >)" ,
"required" : true
},
{
"name" : "to" ,
"type" : "STRING" ,
"description" : "Dirección del destinatario." ,
"required" : true
},
{
"name" : "subject" ,
"type" : "STRING" ,
"description" : "Asunto del correo electrónico." ,
"required" : true
},
{
"name" : "html" ,
"type" : "STRING" ,
"description" : "Cuerpo del correo en formato HTML." ,
"required" : true
}
]
}
]
}
Import via the UI or POST to /api/import.
Every tool in HandsAI is defined by these core fields:
Human-readable tool name (e.g., “Create GitHub Issue”)
Unique identifier for the tool (e.g., “github-create-issue”). Auto-generated if not provided.
Clear description of what the tool does. This helps AI agents understand when to use it.
API endpoint path relative to the provider’s baseUrl (e.g., “/repos///issues”)
HTTP method: GET, POST, PUT, PATCH, DELETE
List of tool parameters (see Parameter Structure below)
Whether the tool is active and discoverable by MCP clients
Health status (updated by validation checks)
Whether this tool can be exported and shared with others
Parameter Structure
Each tool parameter defines an input argument:
Parameter name (must match API expectations)
Data type: STRING, NUMBER, BOOLEAN, OBJECT, ARRAY
What this parameter does (shown to AI agents)
Whether this parameter is mandatory
Optional default value if not provided by the agent
The ApiTool entity in HandsAI:
@ Entity
@ Table ( name = "api_tools" )
public class ApiTool extends BaseModel {
private String name ;
private String description ;
@ ManyToOne ( fetch = FetchType . LAZY )
@ JoinColumn ( name = "provider_id" , nullable = false )
private ApiProvider provider ;
private String endpointPath ;
@ Enumerated ( EnumType . STRING )
private HttpMethodEnum httpMethod ;
private Instant lastHealthCheck ;
private boolean healthy ;
@ OneToMany ( mappedBy = "apiTool" , cascade = CascadeType . ALL , orphanRemoval = true )
private Set < ToolParameter > parameters = new LinkedHashSet <>();
@ Column ( columnDefinition = "boolean default false" )
private boolean isExportable = false ;
}
Each tool is linked to an ApiProvider which holds authentication and base URL configuration.
Provider Concept
A Provider represents an external API service. Multiple tools can share one provider:
Provider name (e.g., “GitHub REST API”)
Authentication method: NONE, API_KEY, BEARER_TOKEN, BASIC_AUTH
Where to send the API key: HEADER, QUERY_PARAMETER, IN_BODY
Key name (e.g., “Authorization”, “x-api-key”)
The actual API key or token (encrypted before storage)
Optional custom headers as JSON (e.g., {"User-Agent": "HandsAI/1.0"})
Whether authentication requires fetching a token dynamically
Entity Model: ApiProvider
@ Entity
@ Table ( name = "api_providers" )
public class ApiProvider extends BaseModel {
private String name ;
@ Column ( nullable = false )
private String baseUrl ;
@ Enumerated ( EnumType . STRING )
private AuthenticationTypeEnum authenticationType ;
@ Enumerated ( EnumType . STRING )
private ApiKeyLocationEnum apiKeyLocation ;
private String apiKeyName ;
private String apiKeyValue ; // Encrypted
@ Column ( columnDefinition = "TEXT" )
private String customHeadersJson ;
// Dynamic Authentication Fields
@ Column ( columnDefinition = "boolean default false" )
private boolean isDynamicAuth = false ;
private String dynamicAuthUrl ;
@ Enumerated ( EnumType . STRING )
private DynamicAuthMethodEnum dynamicAuthMethod ;
@ Column ( columnDefinition = "TEXT" )
private String dynamicAuthPayload ;
@ Enumerated ( EnumType . STRING )
private DynamicAuthPayloadTypeEnum dynamicAuthPayloadType ;
@ Enumerated ( EnumType . STRING )
private DynamicAuthPayloadLocationEnum dynamicAuthPayloadLocation ;
private String dynamicAuthTokenExtractionPath ;
@ OneToMany ( mappedBy = "provider" , cascade = CascadeType . ALL , orphanRemoval = true )
private List < ApiTool > tools = new ArrayList <>();
@ Column ( columnDefinition = "boolean default false" )
private boolean isExportable = false ;
}
Providers support both static authentication (API keys stored encrypted) and dynamic authentication (tokens fetched on-demand).
HandsAI uses ToolCacheManager to cache tools in a ConcurrentHashMap for blazing-fast lookups during MCP tool discovery and execution.
Cache Initialization
@ Component
public class ToolCacheManager {
private final ApiToolRepository apiToolRepository ;
private final ConcurrentHashMap < String , ApiTool > toolCache = new ConcurrentHashMap <>();
@ PostConstruct
public void initCache () {
log . info ( "Initializing tool cache" );
List < ApiTool > activeTools = apiToolRepository . findAllEnabled ();
activeTools . forEach (tool -> toolCache . put ( tool . getCode (), tool));
log . info ( "Tool cache initialized with {} tools" , activeTools . size ());
}
}
On startup, ToolCacheManager loads all enabled tools from the database into memory.
Cache Operations
Get All Cached Tools (used by MCP discovery):
public List < ApiTool > getAllCachedTools () {
return toolCache . values (). stream ()
. filter (tool -> tool . isEnabled () && tool . isHealthy ())
. toList ();
}
Get Single Tool (used by MCP execution):
public Optional < ApiTool > getCachedTool ( String toolCode) {
return Optional . ofNullable ( toolCache . get (toolCode))
. filter (tool -> tool . isEnabled () && tool . isHealthy ());
}
Add or Update Tool :
public void addOrUpdateTool ( ApiTool tool) {
if ( tool . isEnabled () && tool . isHealthy ()) {
toolCache . put ( tool . getCode (), tool);
log . info ( "Tool {} added/updated in cache" , tool . getCode ());
} else {
toolCache . remove ( tool . getCode ());
log . info ( "Tool {} removed from cache due to disabled state or unhealthy status" , tool . getCode ());
}
}
Refresh Entire Cache :
public int refreshCache () {
List < ApiTool > tools = apiToolRepository . findAllEnabled ();
toolCache . clear ();
tools . forEach (tool -> toolCache . put ( tool . getCode (), tool));
log . info ( "Cache refreshed with {} tools" , tools . size ());
return tools . size ();
}
The cache is automatically refreshed after tool creation, update, or deletion.
Why Caching Matters
Speed : No database queries during tool discovery (critical for fast MCP responses)
Scalability : Supports thousands of tools without performance degradation
Concurrency : ConcurrentHashMap allows safe concurrent access by virtual threads
Filtering : Only enabled and healthy tools are exposed to AI agents
Before tools are cached, HandsAI validates them for:
1. SSRF Protection
HandsAI uses SecurityValidator to prevent Server-Side Request Forgery attacks:
// Validates that baseUrl and endpointPath don't target internal networks
securityValidator . validateUrl ( provider . getBaseUrl ());
securityValidator . validateUrl ( tool . getEndpointPath ());
This blocks attempts to register tools pointing to:
localhost / 127.0.0.1
Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
Cloud metadata endpoints (AWS, GCP, Azure)
2. Health Checks
Tools can be validated for health using ToolValidationService:
@ Override
@ Transactional
public ApiToolResponse validateApiToolHealth ( Long id) {
ApiTool apiTool = apiToolRepository . findById (id)
. orElseThrow (() -> new ResourceNotFoundException ( "ApiTool not found with id: " + id));
validateHealth (apiTool);
return ApiToolResponse . from (apiTool);
}
private void validateHealth ( ApiTool apiTool) {
boolean isHealthy = toolValidationService . validateApiToolHealth (apiTool);
apiTool . setHealthy (isHealthy);
apiTool . setLastHealthCheck ( Instant . now ());
apiToolRepository . save (apiTool);
}
Health checks verify that:
The provider’s base URL is reachable
Authentication credentials are valid
The endpoint responds successfully
3. JSON Schema Generation
HandsAI automatically generates JSON Schema for tool parameters, which is returned in MCP discovery responses:
private McpTool convertToMcpTool ( ToolDefinition toolDef) {
return McpTool . builder ()
. name ( toolDef . name ())
. description ( toolDef . description ())
. inputSchema ( toolDef . parameters ()) // JSON Schema object
. build ();
}
The schema is derived from tool parameters:
{
"type" : "object" ,
"properties" : {
"owner" : {
"type" : "string" ,
"description" : "Repository owner"
},
"repo" : {
"type" : "string" ,
"description" : "Repository name"
}
},
"required" : [ "owner" , "repo" ]
}
This schema allows AI agents to:
Understand parameter types
Know which parameters are required
Generate valid requests
When you create a tool via UI or API, ApiToolServiceImpl handles the process:
@ Override
@ Transactional
public ApiToolResponse createApiTool ( CreateApiToolRequest request) {
log . info ( "Creating new API tool: {}" , request . name ());
// Generate unique code if not provided
String toolCode = ( request . code () != null && ! request . code (). isBlank ())
? request . code ()
: UUID . randomUUID (). toString ();
// Prevent duplicates
if ( apiToolRepository . existsByCode (toolCode)) {
throw new IllegalArgumentException ( "Tool with code " + toolCode + " already exists" );
}
// Fetch provider
ApiProvider provider = apiProviderRepository . findById ( request . providerId ())
. orElseThrow (() -> new ResourceNotFoundException ( "Provider not found with id: " + request . providerId ()));
// Build tool entity
ApiTool apiTool = ApiTool . builder ()
. name ( request . name ())
. code (toolCode)
. description ( request . description ())
. provider (provider)
. endpointPath ( request . endpointPath ())
. httpMethod ( request . httpMethod ())
. enabled ( request . enabled () != null ? request . enabled () : true )
. healthy ( request . enabled () != null ? request . enabled () : true )
. isExportable ( request . isExportable () != null ? request . isExportable () : false )
. createdAt ( Instant . now ())
. updatedAt ( Instant . now ())
. build ();
// Add parameters
if ( request . parameters () != null ) {
Set < ToolParameter > parameters = request . parameters (). stream ()
. map (p -> ToolParameter . builder ()
. apiTool (apiTool)
. name ( p . name ())
. code ( UUID . randomUUID (). toString ())
. type ( p . type ())
. description ( p . description ())
. required ( p . required ())
. defaultValue ( p . defaultValue ())
. enabled ( true )
. createdAt ( Instant . now ())
. updatedAt ( Instant . now ())
. build ())
. collect ( Collectors . toCollection (LinkedHashSet ::new ));
apiTool . setParameters (parameters);
}
ApiTool savedTool = apiToolRepository . save (apiTool);
// Refresh cache immediately
toolCacheManager . refreshCache ();
return ApiToolResponse . from (savedTool);
}
Key steps:
UUID Generation : Tools get unique codes automatically
Duplicate Prevention : Rejects tools with existing codes
Provider Linking : Associates tool with authentication provider
Parameter Creation : Converts DTOs to JPA entities
Cache Refresh : Makes the tool immediately discoverable
Real-World Example: GitHub Issue Creation
Here’s how a complete tool definition looks in practice:
Provider:
{
"name" : "GitHub REST API" ,
"code" : "github" ,
"baseUrl" : "https://api.github.com" ,
"authenticationType" : "BEARER_TOKEN" ,
"apiKeyLocation" : "HEADER" ,
"apiKeyName" : "Authorization" ,
"apiKeyValue" : "ghp_abc123..." ,
"customHeaders" : {
"Accept" : "application/vnd.github+json" ,
"X-GitHub-Api-Version" : "2022-11-28"
}
}
Tool:
{
"name" : "Crear Issue Github" ,
"code" : "github-create-issue" ,
"description" : "Creates a new issue in a GitHub repository." ,
"endpointPath" : "/repos/{owner}/{repo}/issues" ,
"httpMethod" : "POST" ,
"enabled" : true ,
"parameters" : [
{
"name" : "owner" ,
"type" : "STRING" ,
"description" : "Repository owner (user or organization)" ,
"required" : true
},
{
"name" : "repo" ,
"type" : "STRING" ,
"description" : "Repository name" ,
"required" : true
},
{
"name" : "title" ,
"type" : "STRING" ,
"description" : "Issue title" ,
"required" : true
},
{
"name" : "body" ,
"type" : "STRING" ,
"description" : "Issue description (supports Markdown)" ,
"required" : false
}
]
}
When an AI agent calls this tool:
ToolCacheManager retrieves the cached tool by code
DynamicTokenManager checks if authentication is needed (static in this case)
ToolExecutionService interpolates {owner} and {repo} in the endpoint path
The request is sent to https://api.github.com/repos/facebook/react/issues
Response is returned to the agent in MCP format
Next Steps
MCP Protocol Understand how HandsAI implements MCP
Authentication Learn about supported authentication types
API Reference Explore all API endpoints
Import Tools Import pre-configured use cases