Tool Serving

Deep Dive

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

📚 What is this & why does it matter?

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.

💡 Real-world analogy

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.

Contents

Overview

Primary Role

Resolve plugin-provided tools and expose MCP server tools to the agent’s tool registry.

Main Inputs

OpenClawPluginToolContext, tool allowlist, existing tool names, plugin registry.

Key Decisions

Which tools to include (allowlist filtering), how to handle name conflicts, optional vs required tools.

Outputs

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.

How It Works

Tool serving operates through four key stages: fast-path check, registry resolution, factory invocation with metadata attachment, and allowlist & conflict filtering.

Stage 1: Entry & Fast Path

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
}
Key detail: The fast-path check avoids importing jiti and scanning the filesystem for plugin bundles when plugins are disabled, keeping cold-start times low for non-plugin deployments.

Stage 2: Registry Resolution

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;
}

Stage 3: Factory Invocation & Metadata

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);
  }
}

Stage 4: Allowlist & Conflict Filtering

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;
  });
}

Tool Resolution Sequence

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.

Annotated Code Walkthrough

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;
}

Edge Cases & Error Handling

ScenarioBehaviorCode Location
All plugins disabledFast path returns empty array; no plugin discovery or jiti overheadresolvePluginTools()
Tool name collision with built-in toolsSuppressed silently if suppressNameConflicts is true, otherwise logged as warningfilterTools()
Optional tool not in allowlistTool is excluded from the final array; required tools bypass allowlist checksisOptionalToolAllowed()
Factory throws during tool creationError is caught per-factory; other factories continue; partial tools may be includedresolvePluginTools()
Gateway subagent modePlugin identity is bound via withPluginRuntimePluginIdScope() before registry accessresolvePluginToolRegistry()

Directory Map

Source files for tool serving
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