Primary Role
Resolve plugin-provided tools and expose MCP server tools to the agent’s tool registry.
How MCP server tools are exposed to the agent tool registry
The tool serving pipeline resolves plugin-provided tools and MCP server tools, filters them through allowlists, handles name conflicts, and delivers a flattened AnyAgentTool[] ready for the agent orchestrator to invoke.
Tool Resolution Allowlist Filtering Name Normalization Conflict Handling
When plugins register tools (or expose MCP servers), the agent needs a single, conflict-free list of tools it can call. The tool serving module acts as the gatekeeper — it discovers every tool each plugin offers, checks if the tool is allowed, normalizes its name for consistency, and either includes it or skips it if it would collide with an existing built-in tool.
Imagine a food court manager (the tool serving module) opening for the day. Each restaurant (plugin) submits its menu (tool factories). The manager checks each dish against the food court’s approved list (allowlist), renames dishes if needed for consistency (name normalization), and rejects any dish that duplicates an existing offering (conflict suppression). The final combined menu (AnyAgentTool[]) is posted for customers (the agent) to order from.
Resolve plugin-provided tools and expose MCP server tools to the agent’s tool registry.
OpenClawPluginToolContext, tool allowlist, existing tool names, plugin registry.
Which tools to include (allowlist filtering), how to handle name conflicts, optional vs required tools.
AnyAgentTool[] — flattened array of tools ready for the agent to call.
flowchart LR
Context["PluginToolContext"] --> Resolve["resolvePluginTools()"]
Resolve --> Registry["PluginRegistry"]
Registry --> Factories["Plugin Tool Factories"]
Factories --> Filter["Allowlist Filter"]
Filter --> Normalize["normalizeToolName()"]
Normalize --> Conflicts{"Name Conflict?"}
Conflicts -->|Yes, suppress| Skip["Skip tool"]
Conflicts -->|No| Add["Add to tools[]"]
Add --> Output["AnyAgentTool[]"]
The flow begins when resolvePluginTools() is called by the agent orchestrator. It takes the plugin context, an optional tool allowlist, and the set of existing tool names. Each plugin’s tool factory is invoked, and the resulting tools are filtered, normalized, and deduplicated before being returned.
Tool serving operates through four key stages: fast-path check, registry resolution, factory invocation with metadata attachment, and allowlist & conflict filtering.
resolvePluginTools() is the main entry point. It accepts the context, allowlist, and existing tool names. If plugins are effectively disabled (no plugin registry, or all plugins inactive), it returns an empty array immediately — avoiding the overhead of plugin discovery and jiti module loading.
// tools.ts — fast path check
export async function resolvePluginTools(
params: ResolvePluginToolsParams,
): Promise<AnyAgentTool[]> {
const { context, allowlist, existingToolNames, suppressNameConflicts } = params;
// Fast path: skip plugin discovery when effectively disabled
if (!context.pluginsEnabled || !context.pluginRegistry) {
return [];
}
const registry = await resolvePluginToolRegistry(context);
// ... continue to factory invocation
}
jiti and scanning the filesystem for plugin bundles when plugins are disabled, keeping cold-start times low for non-plugin deployments.
resolvePluginToolRegistry() resolves the plugin registry, which maps plugin IDs to their tool factories. In gateway subagent mode, it also handles binding the parent plugin’s identity to the subagent request scope.
// tools.ts — registry resolution
async function resolvePluginToolRegistry(
context: OpenClawPluginToolContext,
): Promise<PluginRegistry> {
const registry = context.pluginRegistry!;
// In gateway subagent mode, bind plugin identity to scope
if (context.gatewaySubagentPluginId) {
return withPluginRuntimePluginIdScope(
context.gatewaySubagentPluginId,
() => registry,
);
}
return registry;
}
Each registered tool factory is a OpenClawPluginToolFactory that receives the context and returns an array of tools. After invocation, PluginToolMeta is attached to each tool via a WeakMap, tracking the originating pluginId and whether the tool is optional.
// tools.ts — factory iteration
for (const [pluginId, factory] of registry.toolFactories) {
const tools = await factory(context);
for (const tool of tools) {
// Attach metadata without modifying the tool object
setPluginToolMeta(tool, { pluginId, optional: factory.optional });
allTools.push(tool);
}
}
Optional tools are checked via isOptionalToolAllowed(), which supports three match levels: exact tool name, plugin ID, or the group:plugins wildcard. Name conflicts are resolved by comparing normalized names against the existing tool set.
// tools.ts — filtering and conflict resolution
function filterTools(
tools: AnyAgentTool[],
allowlist: Set<string>,
existingNames: Set<string>,
suppressNameConflicts: boolean,
): AnyAgentTool[] {
return tools.filter(tool => {
const meta = getPluginToolMeta(tool);
const normalized = normalizeToolName(tool.name);
// Check optional tool allowlist
if (meta?.optional && !isOptionalToolAllowed(normalized, meta.pluginId, allowlist)) {
return false;
}
// Check name conflicts
if (existingNames.has(normalized)) {
if (suppressNameConflicts) return false;
console.warn(`Tool name conflict: "${tool.name}" collides with built-in tool`);
return false;
}
existingNames.add(normalized);
return true;
});
}
sequenceDiagram
participant Agent as Agent Orchestrator
participant Tools as tools.ts
participant Reg as Plugin Registry
participant Factory as Tool Factory
Agent->>Tools: resolvePluginTools(params)
Tools->>Tools: Check plugins disabled (fast path)
Tools->>Reg: resolvePluginToolRegistry()
Reg-->>Tools: PluginRegistry
loop Each tool factory
Tools->>Factory: factory(context)
Factory-->>Tools: AnyAgentTool[]
Tools->>Tools: Attach PluginToolMeta
Tools->>Tools: Check allowlist
Tools->>Tools: Check name conflicts
end
Tools-->>Agent: AnyAgentTool[]
The sequence above shows the complete lifecycle of a resolvePluginTools() call. Note that each factory is invoked sequentially (tools may depend on registration order), and filtering happens inline rather than as a separate pass.
Fast path check — avoid loading plugins when disabled. If pluginsEnabled is false or the registry is null, return an empty array immediately. This prevents importing the jiti runtime and scanning for plugin bundles.
if (!context.pluginsEnabled || !context.pluginRegistry) {
return [];
}
Tool allowlist normalization — normalizeAllowlist() converts the user-provided string list into a Set<string> of normalized names, ensuring case-insensitive and whitespace-trimmed matching.
const normalizedAllowlist = normalizeAllowlist(allowlist ?? []);
// ["my-tool", "GROUP:PLUGINS"] → Set{"my-tool", "group:plugins"}
Optional tool filtering — isOptionalToolAllowed() checks three levels in order: (1) exact tool name in allowlist, (2) plugin ID in allowlist, (3) group:plugins wildcard. First match wins.
function isOptionalToolAllowed(
toolName: string, pluginId: string, allowlist: Set<string>,
): boolean {
if (allowlist.has(toolName)) return true;
if (allowlist.has(pluginId)) return true;
if (allowlist.has('group:plugins')) return true;
return false;
}
Name conflict resolution — normalizeToolName() standardizes names for comparison (lowercase, strip hyphens/underscores). If the normalized name already exists in the set, the tool is either silently skipped or logged as a warning based on the suppressNameConflicts flag.
const normalized = normalizeToolName(tool.name);
if (existingNames.has(normalized)) {
if (suppressNameConflicts) return false;
console.warn(`Tool name conflict: "${tool.name}"`);
return false;
}
| Scenario | Behavior | Code Location |
|---|---|---|
| All plugins disabled | Fast path returns empty array; no plugin discovery or jiti overhead | resolvePluginTools() |
| Tool name collision with built-in tools | Suppressed silently if suppressNameConflicts is true, otherwise logged as warning | filterTools() |
| Optional tool not in allowlist | Tool is excluded from the final array; required tools bypass allowlist checks | isOptionalToolAllowed() |
| Factory throws during tool creation | Error is caught per-factory; other factories continue; partial tools may be included | resolvePluginTools() |
| Gateway subagent mode | Plugin identity is bound via withPluginRuntimePluginIdScope() before registry access | resolvePluginToolRegistry() |
src/plugins/
├── tools.ts ← resolvePluginTools(), filterTools(), factory iteration
├── tool-policy.ts ← normalizeToolName(), normalizeAllowlist()
├── plugin-tool-meta.ts ← PluginToolMeta WeakMap, get/set/copy helpers
├── registry.ts ← PluginRegistry, tool factory registration
└── gateway-request-scope.ts ← withPluginRuntimePluginIdScope() for subagent binding