Primary Role
Manage the lifecycle of plugin-provided tools — from registration through resolution to conflict handling.
Tool resolution, allowlists, name normalization, and conflict management
The plugin tools module manages the full lifecycle of plugin-provided tools — from registration through name normalization to conflict handling. It uses WeakMap-based metadata tracking to annotate tools without modifying their shape, and supports three levels of allowlist matching for fine-grained control.
Name Normalization Allowlist Matching WeakMap Metadata Conflict Detection
Plugins can register tools with arbitrary names, but the agent needs a clean, deduplicated set of tools with no naming collisions. This module is the quality control checkpoint — it normalizes names for consistent comparison, checks each tool against an allowlist, detects conflicts with built-in tools, and tracks metadata about each tool’s origin without modifying the tool object itself.
Think of a patent office (the plugin tools module) processing invention applications (tool registrations). Each application goes through three desks: the naming desk (normalizeToolName — ensures consistent formatting), the approval desk (allowlist check — verifies the invention is on the approved list), and the conflict desk (name collision detection — rejects if an identical patent already exists). Each approved patent gets a sticky note (WeakMap metadata) on the back with the inventor’s ID, but the patent document itself is never altered.
Manage the lifecycle of plugin-provided tools — from registration through resolution to conflict handling.
Tool definitions from plugins, allowlist configuration, existing built-in tool names.
How to normalize tool names, whether optional tools pass the allowlist, how to handle duplicate names.
Clean, deduplicated AnyAgentTool[] with metadata tracking via WeakMap.
flowchart TD
subgraph Input["Tool Sources"]
PluginTools["Plugin registerTool()"]
MCPTools["MCP Server Tools"]
BuiltIn["Built-in Tools"]
end
subgraph Resolution["Resolution Pipeline"]
Normalize["normalizeToolName()"]
AllowCheck["Allowlist Check"]
ConflictCheck["Conflict Detection"]
MetaAttach["Attach PluginToolMeta"]
end
subgraph Output["Result"]
Final["AnyAgentTool[]"]
Meta["WeakMap metadata"]
end
PluginTools --> Normalize
MCPTools --> Normalize
Normalize --> AllowCheck
AllowCheck --> ConflictCheck
BuiltIn --> ConflictCheck
ConflictCheck --> MetaAttach
MetaAttach --> Final
MetaAttach --> Meta
Tools flow from three sources through a resolution pipeline. Plugin-registered and MCP server tools are normalized and filtered, while built-in tools serve as the conflict reference set. The output is a clean array of tools with WeakMap-attached metadata tracking their origin.
Plugin tool management operates through four key mechanisms: name normalization, allowlist matching, metadata attachment, and conflict detection.
normalizeToolName() standardizes tool names for consistent comparison. It converts to lowercase and strips hyphens and underscores, ensuring that my-tool, my_tool, and MyTool are treated as equivalent.
// tool-policy.ts — name normalization
export function normalizeToolName(name: string): string {
return name.toLowerCase().replace(/[-_]/g, '');
}
// Examples:
// normalizeToolName("my-tool") → "mytool"
// normalizeToolName("My_Tool") → "mytool"
// normalizeToolName("myTool") → "mytool"
my-tool and mytool are considered the same name.
normalizeAllowlist() converts the user-provided string list into a Set<string> of normalized names. isOptionalToolAllowed() then checks three levels in order: exact tool name, plugin ID, or the group:plugins wildcard.
// tool-policy.ts — allowlist normalization and matching
export function normalizeAllowlist(allowlist: string[]): Set<string> {
return new Set(allowlist.map(s => s.trim().toLowerCase()));
}
export function isOptionalToolAllowed(
toolName: string,
pluginId: string,
allowlist: Set<string>,
): boolean {
// Level 1: Exact tool name match
if (allowlist.has(toolName)) return true;
// Level 2: Plugin ID match (all tools from this plugin)
if (allowlist.has(pluginId)) return true;
// Level 3: Wildcard (all plugin tools allowed)
if (allowlist.has('group:plugins')) return true;
return false;
}
optional flag) bypass the allowlist entirely.
PluginToolMeta is attached to each tool via a module-scoped WeakMap. This tracks the originating pluginId and whether the tool is optional, without modifying the tool object’s shape or prototype. Helper functions setPluginToolMeta(), getPluginToolMeta(), and copyPluginToolMeta() manage the metadata lifecycle.
// plugin-tool-meta.ts — WeakMap metadata management
interface PluginToolMeta {
pluginId: string;
optional: boolean;
}
const metaMap = new WeakMap<AnyAgentTool, PluginToolMeta>();
export function setPluginToolMeta(
tool: AnyAgentTool,
meta: PluginToolMeta,
): void {
metaMap.set(tool, meta);
}
export function getPluginToolMeta(
tool: AnyAgentTool,
): PluginToolMeta | undefined {
return metaMap.get(tool);
}
export function copyPluginToolMeta(
source: AnyAgentTool,
target: AnyAgentTool,
): void {
const meta = metaMap.get(source);
if (meta) metaMap.set(target, meta);
}
After normalization and allowlist filtering, each tool’s normalized name is checked against the existing set of built-in tool names. The suppressNameConflicts flag controls whether conflicts are silently skipped or logged as warnings.
// tools.ts — conflict detection
const normalized = normalizeToolName(tool.name);
if (existingNames.has(normalized)) {
if (suppressNameConflicts) {
// Silently skip — plugin tool loses to built-in
return false;
}
console.warn(
`Plugin tool "${tool.name}" (from ${meta?.pluginId}) ` +
`conflicts with existing tool "${normalized}"`,
);
return false;
}
// No conflict — add to the set for future checks
existingNames.add(normalized);
stateDiagram-v2 [*] --> Registered : Plugin registers tool Registered --> Normalized : normalizeToolName() Normalized --> AllowlistCheck : Check allowlist AllowlistCheck --> Allowed : In allowlist AllowlistCheck --> Blocked : Not in allowlist Allowed --> ConflictCheck : Check existing names ConflictCheck --> Added : No conflict ConflictCheck --> Suppressed : Conflict + suppress ConflictCheck --> Warning : Conflict + no suppress Blocked --> [*] Added --> [*] Suppressed --> [*] Warning --> [*]
The state machine above shows every possible path a plugin tool can take through the resolution pipeline. A tool either ends up in the final AnyAgentTool[] (Added), is silently dropped (Blocked or Suppressed), or triggers a warning before being dropped (Warning).
normalizeAllowlist() — convert the user’s string list to a Set of normalized names. This ensures case-insensitive and whitespace-trimmed matching throughout the pipeline.
const normalizedAllowlist = normalizeAllowlist(config.toolAllowlist ?? []);
// Input: ["My-Tool", " GROUP:PLUGINS ", "discord-plugin"]
// Output: Set{"my-tool", "group:plugins", "discord-plugin"}
isOptionalToolAllowed() — three-level match cascade. First checks for the exact normalized tool name, then the plugin ID, then the group:plugins wildcard. First match wins, so more specific rules take priority.
// Tool "send-message" from plugin "discord-plugin"
isOptionalToolAllowed("sendmessage", "discord-plugin", allowlist);
// Check 1: allowlist.has("sendmessage") → false
// Check 2: allowlist.has("discord-plugin") → true → ALLOWED
PluginToolMeta WeakMap — attach metadata to the tool object without modifying its shape. The WeakMap key is the tool object reference itself, so metadata is automatically cleaned up when the tool is garbage collected.
setPluginToolMeta(tool, { pluginId: "discord-plugin", optional: true });
// Later, during logging or diagnostics:
const meta = getPluginToolMeta(tool);
console.log(`Tool from plugin: ${meta?.pluginId}`); // "discord-plugin"
Conflict detection loop — compare each tool’s normalized name against the existing set. Built-in tools are added to the set first, so they always win conflicts. Plugin tools are checked in registration order.
for (const tool of pluginTools) {
const norm = normalizeToolName(tool.name);
if (existingNames.has(norm)) {
// Built-in or earlier plugin already claimed this name
continue;
}
existingNames.add(norm);
result.push(tool);
}
| Scenario | Behavior | Code Location |
|---|---|---|
| Empty allowlist | No optional tools are allowed; only required tools pass through | isOptionalToolAllowed() |
group:plugins in allowlist | All optional plugin tools are allowed regardless of name or plugin ID | isOptionalToolAllowed() |
| Tool name normalization edge cases | my-tool, my_tool, and MyTool all normalize to mytool and are treated as conflicts | normalizeToolName() |
| WeakMap garbage collection | Metadata is automatically cleaned up when tool objects are no longer referenced; no manual cleanup needed | plugin-tool-meta.ts |
| Factory returns empty array | No tools from that factory are added; iteration continues to next factory without error | resolvePluginTools() |
src/plugins/
├── tool-policy.ts ← normalizeToolName(), normalizeAllowlist(), isOptionalToolAllowed()
├── plugin-tool-meta.ts ← PluginToolMeta WeakMap, set/get/copy helpers
├── tools.ts ← resolvePluginTools(), conflict detection loop
├── registry.ts ← PluginRegistry, tool factory registration interface
└── types.ts ← AnyAgentTool, OpenClawPluginToolFactory type definitions