Skip to content

Normalize MCP tool names and input schemas before they reach a provider - #123

Merged
shibayan merged 4 commits into
masterfrom
issue-103
Aug 31, 2026
Merged

Normalize MCP tool names and input schemas before they reach a provider#123
shibayan merged 4 commits into
masterfrom
issue-103

Conversation

@shibayan

@shibayan shibayan commented Aug 31, 2026

Copy link
Copy Markdown
Member

What this changes

Fixes #103. MCP puts no restriction on what a server calls a tool, and a zero-argument tool is
commonly declared as a bare { "type": "object" }. Providers accept neither, and both were
forwarded unchanged, so a valid server could produce a request the provider refused or a tool the
model could not name.

The exposed name now replaces every character outside [A-Za-z0-9_.-] with -, and an object
schema gains properties: {} when it has none — written onto a copy, so the server's schema is
never modified. Only the model-facing name changes: tools/call, allowedTools, the
approvalMode callback and error text all keep speaking the server's own name.

McpClientConfig.toolNamePrefix is new: it exposes tools as <prefix>_<name> so two servers that
both advertise search can be told apart. When two of one server's tools would be exposed under
the same name, getTools() rejects and names both — keeping one silently would make the other
unreachable, and which one survived would depend on the order the server listed them in.

Parity

  • Reference checked: Python _mcp.py_normalize_mcp_name (the same character class),
    _build_prefixed_mcp_name (branch for branch, including the trailing/leading separator trims and
    the empty-prefix and empty-name cases), the dict(tool.inputSchema or {}) + missing-properties
    rule, and the collision raise. MCPTool.tool_name_prefix is the grounding for the new config
    field. Go's tool/mcptool/mcp.go normalizeMCPName is a second witness for the character rule,
    and its mcpWrapper splits remote and local names exactly this way. .NET does no normalization —
    it wraps the MCP library's McpClientTool directly — so Python is the authority here.
  • Wire format affected: no
  • Public API affected: yes
  • Breaking change: no

McpClientConfig.toolNamePrefix is added, and getTools() can now reject on a name collision.

MCP tools/call is unchanged — it still sends the server's own name. The provider request changes
by design, and only for servers whose names contained illegal characters or whose object schema
lacked properties; those requests were rejected before, so nothing that worked changes shape.

One deliberate divergence: a missing schema becomes { "type": "object", "properties": {} }
rather than Python's {}. An empty schema says nothing about the arguments; the zero-argument
declaration is what the issue asks for, and it continues this package's previous default.

Checklist

  • pnpm check passes (lint, typecheck, build, test)
  • Behaviour changes are covered by a test that fails without the change
  • Public API changes are reflected in the package README and CHANGELOG.md

MCP puts no restriction on what a server calls a tool, and a zero-argument
tool is commonly declared as a bare `{ "type": "object" }`. Providers accept
neither: OpenAI rejects a function name outside `[A-Za-z0-9_.-]`, and an
object schema with no `properties` is a 400. Both were forwarded unchanged,
so a valid server could produce a request the provider refused or a tool the
model could not name.

The exposed name now replaces every other character with `-`, the rule
Python's `_normalize_mcp_name` and Go's `normalizeMCPName` both apply, so the
same remote tool surfaces under the same name across the SDKs. An object
schema gains `properties: {}` when it has none, and a missing schema becomes
`{ "type": "object", "properties": {} }` — written onto a shallow copy, so
the schema the server owns is never modified. Only the model-facing name
changes: `tools/call`, `allowedTools`, the `approvalMode` callback and the
error text all keep speaking the server's own name.

`McpClientConfig.toolNamePrefix` is new, grounded in Python's
`tool_name_prefix`: it exposes tools as `<prefix>_<name>` so two servers that
both advertise `search` can be told apart, normalizing the prefix the same
way, trimming trailing `_.-` from it and leading `_.-` from the name, and
ignoring a prefix that normalizes away to nothing. Prefixes are the only
thing that separates clients — none infers another's namespace.

When two of one server's tools would be exposed under the same name,
`getTools()` now rejects and names both remote tools and the name they
collide on. Keeping one silently would make the other unreachable, and which
one survived would depend on the order the server listed them in.

Fixes #103

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 31, 2026 03:34
@shibayan shibayan added bug Usage: [PRs], Target: bug fixes and regressions; issues use the Bug issue type public-api-change Usage: [PRs], Target: changes to exported public APIs that require API review labels Aug 31, 2026
@github-actions github-actions Bot added documentation Usage: [Issues, PRs], Target: documentation changes meta Usage: [Issues, PRs], Target: packages/meta mcp Usage: [Issues, PRs], Target: Model Context Protocol integrations labels Aug 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

Pull request overview

This PR hardens MCP tool interoperability by normalizing MCP tool names and input schemas before they reach provider request conversion, preventing provider-side rejections and making tool naming deterministic and provider-safe while preserving server-raw names for execution, filtering, approvals, and diagnostics.

Changes:

  • Normalize provider-facing tool names by replacing characters outside [A-Za-z0-9_.-] with -, while keeping the remote/server tool name for tools/call, allowedTools, approvalMode, and error text.
  • Normalize tool inputSchema by cloning it and ensuring object schemas have properties: {}, and treating missing schemas as { type: 'object', properties: {} }.
  • Add McpClientConfig.toolNamePrefix to namespace exposed tool names as <prefix>_<name>, and reject deterministic name-collisions within a single server’s tool list.
File summaries
File Description
packages/meta/src/mcp-openai-tools.test.ts Adds cross-package test asserting MCP→OpenAI request conversion accepts the normalized zero-arg tool declaration and name.
packages/mcp/src/client.ts Implements tool name/schema normalization, adds toolNamePrefix, and rejects exposed-name collisions deterministically.
packages/mcp/src/client.test.ts Adds unit coverage for schema normalization, name normalization, prefix behavior, and collision rejection.
packages/mcp/README.md Documents the new normalization behavior and toolNamePrefix, including collision semantics.
CHANGELOG.md Announces the MCP normalization behavior and the new public toolNamePrefix API.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/meta/src/mcp-openai-tools.test.ts
The test built an `McpClient` and left it open. The mocked `listTools`
means no connection is ever established today, so nothing leaks — but the
mcp package's own tests close their clients without exception, and a
`McpClient` that started allocating during construction would turn this
into an open handle silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 31, 2026 03:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Review details

Suppressed comments (3)

Previously missed (1) — in code that hasn't changed since the last review.

packages/mcp/src/client.test.ts:196

  • This test creates an McpClient instance but never closes it. Even though listTools is mocked and no connection is opened today, closing keeps the test consistent and prevents potential open-handle leaks if McpClient starts allocating resources during construction or getTools changes.

This issue also appears in the following locations of the same file:

  • line 203
  • line 222
    try {
      const [ping] = await new McpClient({ transport: server() }).getTools();
      expect(ping?.jsonSchema).toEqual({ type: 'object', properties: {} });
    } finally {
      listTools.mockRestore();

packages/mcp/src/client.test.ts:207

  • This test constructs an McpClient via new McpClient(...).getTools() but never closes the client. Closing it keeps test resource management consistent and avoids future open-handle leaks if the client starts allocating resources even when listTools is mocked.
    try {
      const [ping] = await new McpClient({ transport: server() }).getTools();
      expect(ping?.jsonSchema).toEqual({ type: 'string', description: 'raw' });
    } finally {
      listTools.mockRestore();

packages/mcp/src/client.test.ts:226

  • This test creates an McpClient but never closes it in the finally block. Even if listTools is mocked and close is currently a no-op, adding await mcp.close() avoids potential open-handle leaks and matches the cleanup pattern used elsewhere in this file.
      expect(Object.hasOwn(declaredSchema, 'properties')).toBe(false);
      expect(ping?.jsonSchema).not.toBe(declaredSchema);
    } finally {
      listTools.mockRestore();
    }
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Seven of the ten clients this file creates are closed; the three schema
normalization tests built theirs inline and dropped the reference, so
there was nothing left to close. Hold each one and close it in the finally,
the way every other test here does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 31, 2026 03:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings August 31, 2026 04:00
@shibayan
shibayan merged commit 22ecff8 into master Aug 31, 2026
9 checks passed
@shibayan
shibayan deleted the issue-103 branch August 31, 2026 04:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Usage: [PRs], Target: bug fixes and regressions; issues use the Bug issue type documentation Usage: [Issues, PRs], Target: documentation changes mcp Usage: [Issues, PRs], Target: Model Context Protocol integrations meta Usage: [Issues, PRs], Target: packages/meta public-api-change Usage: [PRs], Target: changes to exported public APIs that require API review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP tool schemas and names are passed through unnormalized and can produce provider 400s

2 participants