ConduitMcp.Client.Naming (ConduitMCP v0.11.0)

Copy Markdown View Source

Builds the exposed (model-facing) tool names for one or more servers.

build/2 turns [{server_id, [%Tool{}]}] into a ConduitMcp.Client.ToolSet plus a report of every tool it left out, in four steps:

  1. Spec charset. A tool whose name is not 1-128 characters of [A-Za-z0-9_.-] is rejected with reason :invalid_name. This catches homoglyphs, zero-width and bidi characters, spaces and empty names before any mangling can hide them.
  2. Mangle. The exposed name is prefix <> server_id <> "__" <> sanitize(tool), where sanitize/1 maps every character outside [A-Za-z0-9_-] to _. With namespace: false (a single server) it is prefix <> sanitize(tool).
  3. Length. A name over 64 bytes becomes prefix <> "trunc_" <> hex8, where hex8 is the first 8 lower-case hex digits of sha256(server_id <> <<0>> <> tool).
  4. Collisions. When two or more tools mangle to the same exposed name, all of them are rejected with reason :collision. Picking a winner would let one server shadow another's tool.

Every exposed name therefore matches ^[A-Za-z0-9_-]{1,64}$, and the mapping from accepted tools to exposed names is injective by construction.

Server ids

A server_id is operator configuration, never the self-reported serverInfo.name. It must match ^[a-z][a-z0-9_]{0,31}$, must not contain __ and must not end in _. The last two rules make the __ separator unambiguous: without them "a_" <> "__" <> "x" and "a" <> "__" <> "_x" would both be "a___x". validate_server_ids!/1 also rejects duplicates.

Report

build/2 returns {toolset, report} where report is

%{
  rejected: [{server_id, tool_name, :invalid_name | :collision}],
  collisions: [{exposed_name, [{server_id, tool_name}, ...]}]
}

in input order, so the runtime can emit telemetry and log a warning for each entry. This module emits nothing itself.

Summary

Functions

Builds the toolset and its report.

The exposed name for server_id's tool tool_name, before collision handling. Takes the same :prefix and :namespace options as build/2 but does not validate them.

Maps every character outside [A-Za-z0-9_-] to _ (one _ per character, not per byte; an invalid UTF-8 sequence also becomes _).

Whether name satisfies the spec tool-name charset: 1-128 characters of [A-Za-z0-9_.-].

Checks one server id against the naming rules.

Validates a list of server ids: each must pass validate_server_id/1 and none may repeat. Raises ArgumentError naming the offending id.

Types

reason()

@type reason() :: :invalid_name | :collision

report()

@type report() :: %{
  rejected: [{server_id(), String.t(), reason()}],
  collisions: [
    {ConduitMcp.Client.ToolSet.exposed_name(), [{server_id(), String.t()}, ...]}
  ]
}

server_id()

@type server_id() :: String.t()

Functions

build(servers, opts \\ [])

Builds the toolset and its report.

servers is [{server_id, tools}] in configuration order. Each tool's server_id must equal the server id it is listed under.

Options

  • :prefix - a string of [A-Za-z0-9_-] prepended to every exposed name, at most 50 bytes. It counts toward the 64-byte limit and is kept on truncated names. Default "".
  • :namespace - true (default) puts server_id <> "__" in front of each tool name. false exposes bare (sanitized) tool names and accepts exactly one server; its id is then not checked against the server id rules.

Raises ArgumentError on an invalid or duplicate server id, a bad :prefix, namespace: false with more than one server, or a tool whose server_id differs from the one it is listed under.

Example

iex> {:ok, a} = ConduitMcp.Client.Tool.from_wire("files", %{"name" => "fs.read", "inputSchema" => %{}})
iex> {:ok, b} = ConduitMcp.Client.Tool.from_wire("files", %{"name" => "fs_read", "inputSchema" => %{}})
iex> {:ok, c} = ConduitMcp.Client.Tool.from_wire("files", %{"name" => "list", "inputSchema" => %{}})
iex> {toolset, report} = ConduitMcp.Client.Naming.build([{"files", [a, b, c]}])
iex> ConduitMcp.Client.ToolSet.names(toolset)
["files__list"]
iex> report.collisions
[{"files__fs_read", [{"files", "fs.read"}, {"files", "fs_read"}]}]

exposed_name(server_id, tool_name, opts \\ [])

The exposed name for server_id's tool tool_name, before collision handling. Takes the same :prefix and :namespace options as build/2 but does not validate them.

sanitize(name)

@spec sanitize(String.t()) :: String.t()

Maps every character outside [A-Za-z0-9_-] to _ (one _ per character, not per byte; an invalid UTF-8 sequence also becomes _).

iex> ConduitMcp.Client.Naming.sanitize("fs.read-all")
"fs_read-all"

valid_tool_name?(name)

@spec valid_tool_name?(term()) :: boolean()

Whether name satisfies the spec tool-name charset: 1-128 characters of [A-Za-z0-9_.-].

validate_server_id(id)

@spec validate_server_id(term()) ::
  :ok
  | {:error,
     :not_a_string | :invalid_format | :double_underscore | :trailing_underscore}

Checks one server id against the naming rules.

Returns :ok or {:error, reason} with reason one of :not_a_string, :invalid_format, :double_underscore, :trailing_underscore.

iex> ConduitMcp.Client.Naming.validate_server_id("github")
:ok
iex> ConduitMcp.Client.Naming.validate_server_id("git__hub")
{:error, :double_underscore}

validate_server_ids!(ids)

@spec validate_server_ids!([term()]) :: :ok

Validates a list of server ids: each must pass validate_server_id/1 and none may repeat. Raises ArgumentError naming the offending id.