Conversation
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>
There was a problem hiding this comment.
🟢 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 fortools/call,allowedTools,approvalMode, and error text. - Normalize tool
inputSchemaby cloning it and ensuring object schemas haveproperties: {}, and treating missing schemas as{ type: 'object', properties: {} }. - Add
McpClientConfig.toolNamePrefixto 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.
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>
There was a problem hiding this comment.
🔵 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>
# Conflicts: # CHANGELOG.md
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 wereforwarded 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 objectschema gains
properties: {}when it has none — written onto a copy, so the server's schema isnever modified. Only the model-facing name changes:
tools/call,allowedTools, theapprovalModecallback and error text all keep speaking the server's own name.McpClientConfig.toolNamePrefixis new: it exposes tools as<prefix>_<name>so two servers thatboth advertise
searchcan be told apart. When two of one server's tools would be exposed underthe same name,
getTools()rejects and names both — keeping one silently would make the otherunreachable, and which one survived would depend on the order the server listed them in.
Parity
_mcp.py—_normalize_mcp_name(the same character class),_build_prefixed_mcp_name(branch for branch, including the trailing/leading separator trims andthe empty-prefix and empty-name cases), the
dict(tool.inputSchema or {})+ missing-propertiesrule, and the collision raise.
MCPTool.tool_name_prefixis the grounding for the new configfield. Go's
tool/mcptool/mcp.gonormalizeMCPNameis a second witness for the character rule,and its
mcpWrappersplits remote and local names exactly this way. .NET does no normalization —it wraps the MCP library's
McpClientTooldirectly — so Python is the authority here.McpClientConfig.toolNamePrefixis added, andgetTools()can now reject on a name collision.MCP
tools/callis unchanged — it still sends the server's own name. The provider request changesby 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-argumentdeclaration is what the issue asks for, and it continues this package's previous default.
Checklist
pnpm checkpasses (lint, typecheck, build, test)CHANGELOG.md