From 3a2801905fd577f8533196762686d746a5f52e79 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Tue, 15 Sep 2026 13:46:04 +0300 Subject: [PATCH 1/7] fix(mcp): inject send_feedback once, on the first tools/list page The SDK appended the virtual send_feedback tool to every tools/list page, so a cursor-following client saw it once per page. Now the tool is appended only to the first page (the request with no cursor; an empty-string cursor is a continuation page), which every client reads. Name conflicts are the MCP owner's responsibility. Detection is cheap and page-local: a real first-page tool with the name wins (the SDK warns, skips injection, and forwards its calls through the existing first-page probe), and a real tool on a later page is shadowed, with a warning logged when a client fetches that page. Both warnings point at the collectFeedback.toolName rename option. Generated-By: PostHog Desktop Task-Id: 3b751ad5-c551-4ad2-b90b-e50438622b88 --- .changeset/mcp-feedback-pagination.md | 5 + packages/mcp/README.md | 15 + .../mcp/harness/dual-era/probe-feedback.mjs | 180 ++++++++++++ .../mcp/harness/dual-era/probe-pagination.mjs | 278 ++++++++++++++---- packages/mcp/package.json | 4 +- .../src/__tests__/tools-list-envelope.test.ts | 152 +++++++++- .../mcp/src/extensions/instrumentation.ts | 17 +- 7 files changed, 585 insertions(+), 66 deletions(-) create mode 100644 .changeset/mcp-feedback-pagination.md create mode 100644 packages/mcp/harness/dual-era/probe-feedback.mjs diff --git a/.changeset/mcp-feedback-pagination.md b/.changeset/mcp-feedback-pagination.md new file mode 100644 index 0000000000..6cbf28e4e8 --- /dev/null +++ b/.changeset/mcp-feedback-pagination.md @@ -0,0 +1,5 @@ +--- +'@posthog/mcp': patch +--- + +Inject the send_feedback tool only on the first tools/list page (the request with no cursor), so a paginated catalogue's concatenated listing carries it once instead of once per page. A real first-page tool with the same name still wins: the SDK warns, skips injection, and forwards its calls. A real tool that only appears on a later page is not detected — the SDK logs a warning when a client fetches that page; rename the SDK's tool with `collectFeedback: { toolName }` if your catalogue uses the name. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 67ba81b839..3d1e3e44a4 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -282,6 +282,21 @@ when `tools/list` and `tools/call` reach different server replicas. both advertises both tools. Like `get_more_tools`, a real tool that already uses the configured name wins: the SDK warns, skips injection, and delegates calls to the real handler. +On a paginated catalogue (a `tools/list` response with a `nextCursor`), `instrument()` injects +`send_feedback` on the first page only — the page every client reads, including clients that never +follow `nextCursor` — so a compliant client's concatenated list carries it once. "First page" means +a `tools/list` request with no cursor; an empty string is a valid cursor, so `cursor: ""` is a +continuation page. Hosts using `prepareToolList()` directly own this rule themselves: pass +`collectFeedback: true` only for the first page. + +Name collisions are detected on the first page only. A real tool named `send_feedback` (or +`get_more_tools`) on the first page wins: the SDK warns, skips injection, and forwards its calls. A +real tool that only appears on a **later** page is not detected up front — the SDK's virtual tool is +injected and intercepts calls to the name, so the real tool is shadowed and a concatenated listing +carries the name twice. The SDK logs a warning when a client fetches the colliding page, but the fix +is yours: rename the SDK's tools with `collectFeedback: { toolName: "..." }` and the +`missingCapabilityToolName` option. + ### If you switched to `instrument(server.server)` Before v2 support landed, the compatibility gate rejected high-level v2 servers, and the usual diff --git a/packages/mcp/harness/dual-era/probe-feedback.mjs b/packages/mcp/harness/dual-era/probe-feedback.mjs new file mode 100644 index 0000000000..80030ee030 --- /dev/null +++ b/packages/mcp/harness/dual-era/probe-feedback.mjs @@ -0,0 +1,180 @@ +// send_feedback listing probe — the simple case, on real servers of both majors. +// +// A server instrumented with `collectFeedback: true` must advertise the virtual +// send_feedback tool in tools/list next to the application's own tools, with the +// descriptor agents key off: the two required fields, the enum'd feedback_type, +// and the read-only/idempotent annotations that make agents willing to call it. +// The unit suite proves this against the in-memory v1 server only; this probe +// proves it against a real server of each major (paginated catalogues have +// their own fixture in probe-pagination.mjs). +// +// node probe-feedback.mjs +import { createServer } from 'node:http' +import { setTimeout as sleep } from 'node:timers/promises' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { Server as V1Server } from '@modelcontextprotocol/sdk/server/index.js' +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListToolsResultSchema, +} from '@modelcontextprotocol/sdk/types.js' +import { Server as V2Server, createMcpHandler } from '@modelcontextprotocol/server' +import { toNodeHandler } from '@modelcontextprotocol/node' +import { instrument } from '@posthog/mcp' +import { createRecorder } from './shared/posthog.mjs' + +const GREEN = '\x1b[32m' +const RED = '\x1b[31m' +const RESET = '\x1b[0m' +const results = [] +const check = (name, ok, detail = '') => { + results.push({ name, ok }) + console.log(` ${ok ? GREEN + '✓' : RED + '✗'}${RESET} ${name}${detail ? ` ${detail}` : ''}`) +} + +const APP_TOOLS = [{ name: 'app_tool', description: 'A real application tool', inputSchema: { type: 'object' } }] + +/** The assertions, identical on both majors — only the transport differs. */ +function assertListing(label, tools) { + const names = tools?.map((t) => t.name) ?? [] + check( + `${label} · send_feedback is advertised exactly once`, + names.filter((n) => n === 'send_feedback').length === 1, + JSON.stringify(names) + ) + check(`${label} · the application tool is still advertised`, names.includes('app_tool')) + + const descriptor = tools?.find((t) => t.name === 'send_feedback') + const required = descriptor?.inputSchema?.required + check( + `${label} · feedback_type and summary are the required fields`, + Array.isArray(required) && + required.length === 2 && + required.includes('feedback_type') && + required.includes('summary'), + JSON.stringify(required) + ) + const properties = descriptor?.inputSchema?.properties ?? {} + const core = [ + 'feedback_type', + 'summary', + 'details', + 'friction_points', + 'suggested_improvement', + 'tool_name', + 'sentiment', + 'task_completed', + ] + check( + `${label} · the core schema properties are advertised`, + core.every((key) => key in properties), + JSON.stringify(Object.keys(properties)) + ) + check( + `${label} · feedback_type enumerates the four categories`, + JSON.stringify(properties.feedback_type?.enum) === + JSON.stringify(['missing_capability', 'issue', 'praise', 'other']) + ) + const annotations = descriptor?.annotations + check( + `${label} · annotations mark it read-only, idempotent, non-destructive`, + annotations?.readOnlyHint === true && + annotations?.idempotentHint === true && + annotations?.destructiveHint === false, + JSON.stringify(annotations) + ) +} + +// ── v1, low-level, in-memory ──────────────────────────────────────────────── +async function probeV1() { + console.log('\nv1 · low-level · collectFeedback listing') + const recorder = createRecorder('probe:feedback:v1') + const server = new V1Server({ name: 'probe-v1', version: '1.0.0' }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: APP_TOOLS })) + server.setRequestHandler(CallToolRequestSchema, async (request) => ({ + content: [{ type: 'text', text: `called: ${request.params.name}` }], + })) + instrument(server, recorder.client, { logger: recorder.logger, collectFeedback: true }) + + const client = new Client({ name: 'probe', version: '1.0.0' }, { capabilities: {} }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + try { + const listing = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) + assertListing('v1', listing.tools) + } finally { + await clientTransport.close?.() + await serverTransport.close?.() + } +} + +// ── v2, low-level, per-request factory, modern era, raw JSON-RPC ──────────── +const MODERN_META = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'probe', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {}, +} + +async function probeV2() { + console.log('\nv2 · low-level · per-request · modern era · collectFeedback listing') + const recorder = createRecorder('probe:feedback:v2') + const handler = createMcpHandler( + () => { + const server = new V2Server({ name: 'probe-v2', version: '1.0.0' }, { capabilities: { tools: {} } }) + server.setRequestHandler('tools/list', async () => ({ tools: APP_TOOLS })) + server.setRequestHandler('tools/call', async (request) => ({ + content: [{ type: 'text', text: `called: ${request.params.name}` }], + })) + instrument(server, recorder.client, { logger: recorder.logger, collectFeedback: true }) + return server + }, + { responseMode: 'json', onerror: (e) => recorder.logger(`handler error: ${e}`) } + ) + const node = toNodeHandler(handler) + const http = createServer((req, res) => node(req, res)) + await new Promise((resolve) => http.listen(0, resolve)) + const port = http.address().port + try { + // A transport or parse failure is a probe result, not a crash: report it + // through `check` so the run still prints a legible tally and exits 1. + let listing + let failure + try { + const res = await fetch(`http://localhost:${port}/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': 'tools/list', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: MODERN_META } }), + }) + listing = JSON.parse(await res.text())?.result + } catch (error) { + failure = String(error) + } + // No settle delay: every assertion below reads `listing`, which is already + // resolved — this probe never inspects the recorder's captured events. + check('v2 · the tools/list request returns a result', listing !== undefined, failure ?? '') + if (listing) { + assertListing('v2', listing.tools) + } + } finally { + http.close() + await sleep(150) + } +} + +const MAJOR = (() => { + const i = process.argv.indexOf('--major') + return i === -1 ? 'all' : process.argv[i + 1] +})() + +if (MAJOR !== 'v2') await probeV1() +if (MAJOR !== 'v1') await probeV2() + +const passed = results.filter((r) => r.ok).length +console.log(`\n${passed}/${results.length} passed`) +process.exit(passed === results.length ? 0 : 1) diff --git a/packages/mcp/harness/dual-era/probe-pagination.mjs b/packages/mcp/harness/dual-era/probe-pagination.mjs index 5b4a5af3e2..4f5659befc 100644 --- a/packages/mcp/harness/dual-era/probe-pagination.mjs +++ b/packages/mcp/harness/dual-era/probe-pagination.mjs @@ -18,6 +18,18 @@ // // Both majors, because the defect is in our wrapper and not in either SDK. // +// The probe also runs with `collectFeedback: true`: the virtual send_feedback +// tool must appear on exactly the first page (a compliant client concatenates +// every page, so a per-page append duplicates it — and the first page is the +// one every client reads), and calling it must return the acknowledgement +// instead of reaching a real handler. +// +// A second, colliding catalogue puts a real send_feedback on the first page: +// the SDK must not inject its own, and the real owner must keep its calls. +// Collisions are only detected on the first page — a real owner on a later +// page is the host's responsibility, fixed by renaming the SDK's tool with +// `collectFeedback: { toolName }`. +// // node probe-pagination.mjs import { createServer } from 'node:http' import { setTimeout as sleep } from 'node:timers/promises' @@ -61,12 +73,39 @@ const listPage = (cursor) => const callResult = (name) => ({ content: [{ type: 'text', text: `called: ${name}` }] }) -/** The four assertions, identical on both majors — only the transport differs. */ -function assertEnumeration(label, firstPage, secondPage, callText) { +const FEEDBACK_ARGS = { feedback_type: 'praise', summary: 'Probe feedback.' } + +const REAL_FEEDBACK_TOOL = [ + { name: 'send_feedback', description: 'A real application tool that owns the name', inputSchema: { type: 'object' } }, +] + +/** The first page owns the virtual tool's name, so the SDK must not inject its own. */ +const collidingListPage = (cursor) => + cursor === 'page-2' ? { tools: PAGE_TWO } : { tools: REAL_FEEDBACK_TOOL, nextCursor: 'page-2' } + +/** Page one hands out an empty-string cursor, which the spec calls valid. */ +const emptyCursorListPage = (cursor) => (cursor === '' ? { tools: PAGE_TWO } : { tools: PAGE_ONE, nextCursor: '' }) + +const feedbackCount = (page) => page?.tools?.filter((t) => t.name === 'send_feedback').length ?? -1 + +/** Read as "no cursor", the empty string puts the virtual tool on both pages. */ +function assertEmptyCursor(label, firstPage, secondPage) { + check( + `${label} · empty-string cursor · send_feedback is listed once, on the first page`, + feedbackCount(firstPage) === 1 && feedbackCount(secondPage) === 0, + JSON.stringify({ + pageOne: firstPage?.tools?.map((t) => t.name), + pageTwo: secondPage?.tools?.map((t) => t.name), + }) + ) +} + +/** The assertions, identical on both majors — only the transport differs. */ +function assertEnumeration(label, firstPage, secondPage, callText, feedbackCallText) { check(`${label} · page one keeps nextCursor`, firstPage?.nextCursor === 'page-2', String(firstPage?.nextCursor)) check( `${label} · page two is reachable`, - secondPage?.tools?.length === 1 && secondPage.tools[0].name === 'page_two_tool', + secondPage?.tools?.some((t) => t.name === 'page_two_tool') === true, JSON.stringify(secondPage?.tools?.map((t) => t.name)) ) // Enumeration is only worth anything if the tools it reaches are callable. @@ -78,40 +117,112 @@ function assertEnumeration(label, firstPage, secondPage, callText) { firstPage?._meta?.['com.posthog/probe'] === 'kept', JSON.stringify({ ttlMs: firstPage?.ttlMs, cacheScope: firstPage?.cacheScope, _meta: firstPage?._meta }) ) + check( + `${label} · send_feedback appears only on the first page, once`, + feedbackCount(firstPage) === 1 && feedbackCount(secondPage) === 0, + JSON.stringify({ pageOne: feedbackCount(firstPage), pageTwo: feedbackCount(secondPage) }) + ) + check( + `${label} · a send_feedback call returns the acknowledgement`, + /feedback was recorded/.test(feedbackCallText ?? ''), + feedbackCallText?.slice(0, 120) + ) +} + +/** + * The colliding-catalogue assertions: the real first-page owner blocks + * injection everywhere, and a send_feedback call must reach the real handler, + * not the acknowledgement. + */ +function assertCollision(label, pages, feedbackCallText) { + check( + `${label} · colliding catalogue · only the real send_feedback is listed, on the first page`, + feedbackCount(pages[0]) === 1 && feedbackCount(pages[1]) === 0, + JSON.stringify(pages.map((page) => page?.tools?.map((t) => t.name))) + ) + check( + `${label} · colliding catalogue · the real owner keeps its calls`, + /called: send_feedback/.test(feedbackCallText ?? ''), + feedbackCallText?.slice(0, 160) + ) } // ── v1, low-level, in-memory ──────────────────────────────────────────────── -async function probeV1() { - console.log('\nv1 · low-level · paginated catalogue') - const recorder = createRecorder('probe:pagination:v1') +async function callTool(client, name, args) { + try { + const result = await client.request( + { method: 'tools/call', params: { name, arguments: args } }, + CallToolResultSchema + ) + return JSON.stringify(result) + } catch (error) { + return `error: ${error}` + } +} + +async function connectV1(listHandler, recorder) { const server = new V1Server({ name: 'probe-v1', version: '1.0.0' }, { capabilities: { tools: {} } }) - server.setRequestHandler(ListToolsRequestSchema, async (request) => listPage(request.params?.cursor)) + server.setRequestHandler(ListToolsRequestSchema, async (request) => listHandler(request.params?.cursor)) server.setRequestHandler(CallToolRequestSchema, async (request) => callResult(request.params.name)) - instrument(server, recorder.client, { logger: recorder.logger }) + instrument(server, recorder.client, { logger: recorder.logger, collectFeedback: true }) const client = new Client({ name: 'probe', version: '1.0.0' }, { capabilities: {} }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + return { + client, + async cleanup() { + await clientTransport.close?.() + await serverTransport.close?.() + }, + } +} + +async function listBothPagesV1(client) { + const firstPage = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) + const secondPage = await client.request( + { method: 'tools/list', params: { cursor: firstPage.nextCursor } }, + ListToolsResultSchema + ) + return [firstPage, secondPage] +} + +async function probeV1() { + console.log('\nv1 · low-level · paginated catalogue') + const recorder = createRecorder('probe:pagination:v1') + const { client, cleanup } = await connectV1(listPage, recorder) try { - const firstPage = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) - const secondPage = await client.request( - { method: 'tools/list', params: { cursor: firstPage.nextCursor } }, - ListToolsResultSchema - ) - let callText = '' - try { - const result = await client.request( - { method: 'tools/call', params: { name: 'page_two_tool', arguments: {} } }, - CallToolResultSchema - ) - callText = JSON.stringify(result) - } catch (error) { - callText = `error: ${error}` - } - assertEnumeration('v1', firstPage, secondPage, callText) + const [firstPage, secondPage] = await listBothPagesV1(client) + const callText = await callTool(client, 'page_two_tool', {}) + const feedbackCallText = await callTool(client, 'send_feedback', FEEDBACK_ARGS) + assertEnumeration('v1', firstPage, secondPage, callText, feedbackCallText) } finally { - await clientTransport.close?.() - await serverTransport.close?.() + await cleanup() + } +} + +async function probeCollisionV1() { + console.log('\nv1 · low-level · colliding paginated catalogue') + const recorder = createRecorder('probe:pagination:collision:v1') + const { client, cleanup } = await connectV1(collidingListPage, recorder) + try { + const pages = await listBothPagesV1(client) + const feedbackCallText = await callTool(client, 'send_feedback', FEEDBACK_ARGS) + assertCollision('v1', pages, feedbackCallText) + } finally { + await cleanup() + } +} + +async function probeEmptyCursorV1() { + console.log('\nv1 · low-level · catalogue paginated by an empty-string cursor') + const recorder = createRecorder('probe:pagination:empty-cursor:v1') + const { client, cleanup } = await connectV1(emptyCursorListPage, recorder) + try { + const [firstPage, secondPage] = await listBothPagesV1(client) + assertEmptyCursor('v1', firstPage, secondPage) + } finally { + await cleanup() } } @@ -137,15 +248,26 @@ async function post(port, body, headers = {}) { return { text, result: JSON.parse(text)?.result } } -async function probeV2() { - console.log('\nv2 · low-level · per-request · modern era · paginated catalogue') - const recorder = createRecorder('probe:pagination:v2') +const postFeedbackCall = (port) => + post( + port, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'send_feedback', arguments: FEEDBACK_ARGS, _meta: MODERN_META }, + }, + { 'mcp-method': 'tools/call', 'mcp-name': 'send_feedback' } + ) + +/** A per-request factory: a fresh instrumented instance serves every request. */ +async function startV2(listHandler, recorder) { const handler = createMcpHandler( () => { const server = new V2Server({ name: 'probe-v2', version: '1.0.0' }, { capabilities: { tools: {} } }) - server.setRequestHandler('tools/list', async (request) => listPage(request.params?.cursor)) + server.setRequestHandler('tools/list', async (request) => listHandler(request.params?.cursor)) server.setRequestHandler('tools/call', async (request) => callResult(request.params.name)) - instrument(server, recorder.client, { logger: recorder.logger }) + instrument(server, recorder.client, { logger: recorder.logger, collectFeedback: true }) return server }, { responseMode: 'json', onerror: (e) => recorder.logger(`handler error: ${e}`) } @@ -153,28 +275,35 @@ async function probeV2() { const node = toNodeHandler(handler) const http = createServer((req, res) => node(req, res)).listen(0) await sleep(300) - const port = http.address().port + return { + port: http.address().port, + async cleanup() { + http.close() + await sleep(150) + }, + } +} + +async function listBothPagesV2(port) { + const first = await post( + port, + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: MODERN_META } }, + { 'mcp-method': 'tools/list' } + ) + const second = await post( + port, + { jsonrpc: '2.0', id: 2, method: 'tools/list', params: { cursor: first.result?.nextCursor, _meta: MODERN_META } }, + { 'mcp-method': 'tools/list' } + ) + return [first, second] +} + +async function probeV2() { + console.log('\nv2 · low-level · per-request · modern era · paginated catalogue') + const recorder = createRecorder('probe:pagination:v2') + const { port, cleanup } = await startV2(listPage, recorder) try { - const first = await post( - port, - { - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: { _meta: MODERN_META }, - }, - { 'mcp-method': 'tools/list' } - ) - const second = await post( - port, - { - jsonrpc: '2.0', - id: 2, - method: 'tools/list', - params: { cursor: first.result?.nextCursor, _meta: MODERN_META }, - }, - { 'mcp-method': 'tools/list' } - ) + const [first, second] = await listBothPagesV2(port) const call = await post( port, { @@ -185,11 +314,38 @@ async function probeV2() { }, { 'mcp-method': 'tools/call', 'mcp-name': 'page_two_tool' } ) + const feedbackCall = await postFeedbackCall(port) + await sleep(200) + assertEnumeration('v2', first.result, second.result, call.text, feedbackCall.text) + } finally { + await cleanup() + } +} + +async function probeCollisionV2() { + console.log('\nv2 · low-level · per-request · colliding paginated catalogue') + const recorder = createRecorder('probe:pagination:collision:v2') + const { port, cleanup } = await startV2(collidingListPage, recorder) + try { + const [first, second] = await listBothPagesV2(port) + const feedbackCall = await postFeedbackCall(port) await sleep(200) - assertEnumeration('v2', first.result, second.result, call.text) + assertCollision('v2', [first.result, second.result], feedbackCall.text) } finally { - http.close() - await sleep(150) + await cleanup() + } +} + +async function probeEmptyCursorV2() { + console.log('\nv2 · low-level · per-request · catalogue paginated by an empty-string cursor') + const recorder = createRecorder('probe:pagination:empty-cursor:v2') + const { port, cleanup } = await startV2(emptyCursorListPage, recorder) + try { + const [first, second] = await listBothPagesV2(port) + await sleep(200) + assertEmptyCursor('v2', first.result, second.result) + } finally { + await cleanup() } } @@ -198,8 +354,16 @@ const MAJOR = (() => { return i === -1 ? 'all' : process.argv[i + 1] })() -if (MAJOR !== 'v2') await probeV1() -if (MAJOR !== 'v1') await probeV2() +if (MAJOR !== 'v2') { + await probeV1() + await probeCollisionV1() + await probeEmptyCursorV1() +} +if (MAJOR !== 'v1') { + await probeV2() + await probeCollisionV2() + await probeEmptyCursorV2() +} const passed = results.filter((r) => r.ok).length console.log(`\n${passed}/${results.length} passed`) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 83a95da7fb..01ce76b3a1 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -18,8 +18,8 @@ "pretest:unit": "pnpm generate-version", "test:unit": "vitest run", "test:integration": "pnpm test:integration:sdk-v1 && pnpm test:integration:sdk-v2 && pnpm test:integration:nest-v1 && pnpm test:integration:nest-v2", - "test:integration:sdk-v1": "node harness/dual-era/matrix.mjs --major v1 && node harness/dual-era/probe-late-handlers.mjs --major v1 && node harness/dual-era/probe-pagination.mjs --major v1 && node harness/dual-era/probe-mcp-apps.mjs --major v1", - "test:integration:sdk-v2": "node harness/dual-era/matrix.mjs --major v2 && node harness/dual-era/probe-late-handlers.mjs --major v2 && node harness/dual-era/probe-first-call-error.mjs && node harness/dual-era/probe-pagination.mjs --major v2 && node harness/dual-era/probe-mcp-apps.mjs --major v2", + "test:integration:sdk-v1": "node harness/dual-era/matrix.mjs --major v1 && node harness/dual-era/probe-late-handlers.mjs --major v1 && node harness/dual-era/probe-pagination.mjs --major v1 && node harness/dual-era/probe-feedback.mjs --major v1 && node harness/dual-era/probe-mcp-apps.mjs --major v1", + "test:integration:sdk-v2": "node harness/dual-era/matrix.mjs --major v2 && node harness/dual-era/probe-late-handlers.mjs --major v2 && node harness/dual-era/probe-first-call-error.mjs && node harness/dual-era/probe-pagination.mjs --major v2 && node harness/dual-era/probe-feedback.mjs --major v2 && node harness/dual-era/probe-mcp-apps.mjs --major v2", "test:integration:nest-v1": "node harness/nest-v1/verify.mjs && LEVEL=low node harness/nest-v1/verify.mjs", "test:integration:nest-v2": "node harness/nest-v2/verify.mjs && LEVEL=low node harness/nest-v2/verify.mjs", "prebuild": "pnpm generate-version", diff --git a/packages/mcp/src/__tests__/tools-list-envelope.test.ts b/packages/mcp/src/__tests__/tools-list-envelope.test.ts index 58a466aff1..96c9d38ef4 100644 --- a/packages/mcp/src/__tests__/tools-list-envelope.test.ts +++ b/packages/mcp/src/__tests__/tools-list-envelope.test.ts @@ -9,6 +9,7 @@ import { } from '@modelcontextprotocol/sdk/types.js' import { instrument } from '../index' import { MCPAnalyticsEventType } from '../extensions/event-types' +import { SEND_FEEDBACK_TOOL_NAME } from '../extensions/feedback' import { EventCapture, fakePostHog } from './test-utils' /** @@ -21,15 +22,21 @@ import { EventCapture, fakePostHog } from './test-utils' const PAGE_ONE = [{ name: 'page_one_tool', description: 'On page one', inputSchema: { type: 'object' as const } }] const PAGE_TWO = [{ name: 'page_two_tool', description: 'On page two', inputSchema: { type: 'object' as const } }] -/** A paginated catalogue: page one advertises a cursor, page two ends the enumeration. */ -function setupPaginatedServer(listResponses?: { firstPage?: Record }) { +/** + * A paginated catalogue: page one advertises a cursor, page two ends the + * enumeration. Pass `cursorToken: ''` to paginate by the empty-string cursor. + */ +function setupPaginatedServer( + listResponses?: { firstPage?: Record; secondPage?: Record }, + cursorToken = 'page-2' +) { const server = new Server({ name: 'paginated test', version: '1.0.0' }, { capabilities: { tools: {} } }) server.setRequestHandler(ListToolsRequestSchema, async (request) => { - if (request.params?.cursor === 'page-2') { - return { tools: PAGE_TWO } + if (request.params?.cursor === cursorToken) { + return listResponses?.secondPage ?? { tools: PAGE_TWO } } - return listResponses?.firstPage ?? { tools: PAGE_ONE, nextCursor: 'page-2' } + return listResponses?.firstPage ?? { tools: PAGE_ONE, nextCursor: cursorToken } }) server.setRequestHandler(CallToolRequestSchema, async (request) => ({ @@ -227,4 +234,139 @@ describe('tools/list response envelope', () => { await cleanup() } }) + + /** + * A compliant client concatenates every page into one tool list, so the + * virtual feedback tool may appear on exactly one page — the first one, the + * page every client reads, including clients that never follow `nextCursor`. + * + * Collision detection is deliberately cheap: the served page at list time, + * the raw first page at call time. A real owner on a later page is the + * host's responsibility — the SDK shadows it, warns when a client fetches + * that page, and the host renames the SDK's tool with + * `collectFeedback: { toolName }`. + */ + describe('send_feedback on a paginated catalogue', () => { + const REAL_FEEDBACK_TOOL = { + name: SEND_FEEDBACK_TOOL_NAME, + description: 'A real application tool that owns the name', + inputSchema: { type: 'object' as const }, + } + + /** Walk the whole enumeration, returning the names each page advertised. */ + const listBothPages = async (client: Client, cursorToken = 'page-2'): Promise<[string[], string[]]> => { + const firstPage = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) + const secondPage = await client.request( + { method: 'tools/list', params: { cursor: cursorToken } }, + ListToolsResultSchema + ) + return [firstPage.tools.map((tool) => tool.name), secondPage.tools.map((tool) => tool.name)] + } + + it('appends the virtual tool only to the first page', async () => { + const { server, client, connect, cleanup } = await setupPaginatedServer() + try { + instrument(server, fakePostHog(), { reportMissing: false, collectFeedback: true }) + await connect() + + const [pageOne, pageTwo] = await listBothPages(client) + expect(pageOne).toEqual(['page_one_tool', SEND_FEEDBACK_TOOL_NAME]) + expect(pageTwo).toEqual(['page_two_tool']) + } finally { + await cleanup() + } + }) + + it('a single un-paginated listing still gets the virtual tool', async () => { + const { server, client, connect, cleanup } = await setupPaginatedServer({ + firstPage: { tools: PAGE_ONE }, + }) + try { + instrument(server, fakePostHog(), { reportMissing: false, collectFeedback: true }) + await connect() + + const page = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) + expect(page.tools.map((tool) => tool.name)).toEqual(['page_one_tool', SEND_FEEDBACK_TOOL_NAME]) + } finally { + await cleanup() + } + }) + + // Read as "no cursor", `''` puts the virtual tool on this page as well as + // the first, so a client concatenating the pages sees it twice. + it('treats an empty-string cursor as a continuation page, not the first one', async () => { + const { server, client, connect, cleanup } = setupPaginatedServer(undefined, '') + try { + instrument(server, fakePostHog(), { reportMissing: false, collectFeedback: true }) + await connect() + + const [pageOne, pageTwo] = await listBothPages(client, '') + expect(pageOne).toEqual(['page_one_tool', SEND_FEEDBACK_TOOL_NAME]) + expect(pageTwo).toEqual(['page_two_tool']) + } finally { + await cleanup() + } + }) + + it('a real owner on page one blocks injection and keeps its calls', async () => { + const { server, client, connect, cleanup } = await setupPaginatedServer({ + firstPage: { tools: [REAL_FEEDBACK_TOOL], nextCursor: 'page-2' }, + }) + const warnings: string[] = [] + try { + instrument(server, fakePostHog(), { + reportMissing: false, + collectFeedback: true, + logger: (message: string) => warnings.push(message), + }) + await connect() + + const [pageOne, pageTwo] = await listBothPages(client) + expect(pageOne).toEqual([SEND_FEEDBACK_TOOL_NAME]) + expect(pageTwo).toEqual(['page_two_tool']) + expect(warnings.some((message) => message.includes('Cannot inject agent-feedback tool'))).toBe(true) + + const result = await client.request( + { method: 'tools/call', params: { name: SEND_FEEDBACK_TOOL_NAME, arguments: {} } }, + CallToolResultSchema + ) + expect((result.content as { text: string }[])[0].text).toBe(`called: ${SEND_FEEDBACK_TOOL_NAME}`) + expect(eventCapture.findEventByType(MCPAnalyticsEventType.mcpFeedback)).toBeUndefined() + } finally { + await cleanup() + } + }) + + it('a real owner on a later page is shadowed, with a warning when its page is served', async () => { + const { server, client, connect, cleanup } = await setupPaginatedServer({ + secondPage: { tools: [REAL_FEEDBACK_TOOL] }, + }) + const warnings: string[] = [] + try { + instrument(server, fakePostHog(), { + reportMissing: false, + collectFeedback: true, + logger: (message: string) => warnings.push(message), + }) + await connect() + + // The first page cannot see page two, so the virtual tool is injected + // and the concatenated list carries the name twice. + const [pageOne, pageTwo] = await listBothPages(client) + expect(pageOne).toEqual(['page_one_tool', SEND_FEEDBACK_TOOL_NAME]) + expect(pageTwo).toEqual([SEND_FEEDBACK_TOOL_NAME]) + expect(warnings.some((message) => message.includes('is shadowed by the SDK'))).toBe(true) + + // Calls to the name go to the SDK, not the real tool. + const result = await client.request( + { method: 'tools/call', params: { name: SEND_FEEDBACK_TOOL_NAME, arguments: { summary: 'shadowed' } } }, + CallToolResultSchema + ) + expect((result.content as { text: string }[])[0].text).toContain('recorded') + expect(eventCapture.findEventByType(MCPAnalyticsEventType.mcpFeedback)).toBeDefined() + } finally { + await cleanup() + } + }) + }) }) diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index 18df7fc0d6..508f009597 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -845,9 +845,22 @@ async function getTracedToolsList( if (feedbackOptions) { const feedbackToolName = feedbackOptions.toolName ?? SEND_FEEDBACK_TOOL_NAME const alreadyPresent = tools.some((tool) => tool?.name === feedbackToolName) - if (alreadyPresent) { + // A compliant client concatenates every page into one list, so only the + // first page — the one every client reads, including clients that never + // follow `nextCursor` — may carry the virtual tool. Presence, not + // truthiness: `cursor: ""` is a continuation page. + if (request.params?.cursor != null) { + if (alreadyPresent) { + // Conflicts are only detected on the pages a client actually + // fetches; a real owner here is already shadowed by the first-page + // injection, so the host must rename the SDK's tool. + data.logger( + `Warning: A real tool "${feedbackToolName}" on a later tools/list page is shadowed by the SDK's agent-feedback tool. Its calls will be intercepted. Rename the SDK's tool with collectFeedback: { toolName: "..." } to keep both.` + ) + } + } else if (alreadyPresent) { data.logger( - `Warning: Cannot inject agent-feedback tool "${feedbackToolName}" because a real tool already uses that name. The real tool will not be intercepted.` + `Warning: Cannot inject agent-feedback tool "${feedbackToolName}" because a real tool already uses that name. The real tool will not be intercepted. To collect feedback alongside it, rename the SDK's tool with collectFeedback: { toolName: "..." }.` ) } else { const virtualTool = getFeedbackToolDescriptor(feedbackOptions) From 7dc48a2d72c4e9454e5ee6050b36777c58882123 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Tue, 15 Sep 2026 14:00:54 +0300 Subject: [PATCH 2/7] refactor: apply simplify pass Flatten the nested first-page/collision conditionals into one if/else-if chain keyed on an isFirstPage constant. Behavior unchanged. Generated-By: PostHog Desktop Task-Id: 3b751ad5-c551-4ad2-b90b-e50438622b88 --- .../mcp/src/extensions/instrumentation.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index 508f009597..bb981b891b 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -849,23 +849,22 @@ async function getTracedToolsList( // first page — the one every client reads, including clients that never // follow `nextCursor` — may carry the virtual tool. Presence, not // truthiness: `cursor: ""` is a continuation page. - if (request.params?.cursor != null) { - if (alreadyPresent) { - // Conflicts are only detected on the pages a client actually - // fetches; a real owner here is already shadowed by the first-page - // injection, so the host must rename the SDK's tool. - data.logger( - `Warning: A real tool "${feedbackToolName}" on a later tools/list page is shadowed by the SDK's agent-feedback tool. Its calls will be intercepted. Rename the SDK's tool with collectFeedback: { toolName: "..." } to keep both.` - ) - } - } else if (alreadyPresent) { + const isFirstPage = request.params?.cursor == null + if (isFirstPage && alreadyPresent) { data.logger( `Warning: Cannot inject agent-feedback tool "${feedbackToolName}" because a real tool already uses that name. The real tool will not be intercepted. To collect feedback alongside it, rename the SDK's tool with collectFeedback: { toolName: "..." }.` ) - } else { + } else if (isFirstPage) { const virtualTool = getFeedbackToolDescriptor(feedbackOptions) tools.push(virtualTool) cacheToolAnalyticsParameterOwnership(data.toolAnalyticsParameterOwnership, [virtualTool]) + } else if (alreadyPresent) { + // Conflicts are only detected on the pages a client actually + // fetches; a real owner here is already shadowed by the first-page + // injection, so the host must rename the SDK's tool. + data.logger( + `Warning: A real tool "${feedbackToolName}" on a later tools/list page is shadowed by the SDK's agent-feedback tool. Its calls will be intercepted. Rename the SDK's tool with collectFeedback: { toolName: "..." } to keep both.` + ) } } From 1194ac3963a248de219fd153e17c00de08aa3b03 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Tue, 15 Sep 2026 14:08:32 +0300 Subject: [PATCH 3/7] refactor: apply simplify pass to tests and probes Extract the feedbackOnFirstPageOnly predicate shared by three probe assertions, and give setupPaginatedServer one options object instead of two positional parameters. Behavior unchanged. Generated-By: PostHog Desktop Task-Id: 3b751ad5-c551-4ad2-b90b-e50438622b88 --- packages/mcp/harness/dual-era/probe-pagination.mjs | 10 +++++++--- .../mcp/src/__tests__/tools-list-envelope.test.ts | 14 +++++++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/mcp/harness/dual-era/probe-pagination.mjs b/packages/mcp/harness/dual-era/probe-pagination.mjs index 4f5659befc..dbb8c08541 100644 --- a/packages/mcp/harness/dual-era/probe-pagination.mjs +++ b/packages/mcp/harness/dual-era/probe-pagination.mjs @@ -88,11 +88,15 @@ const emptyCursorListPage = (cursor) => (cursor === '' ? { tools: PAGE_TWO } : { const feedbackCount = (page) => page?.tools?.filter((t) => t.name === 'send_feedback').length ?? -1 +/** True once for the first page, never again — the shape every caller below checks for. */ +const feedbackOnFirstPageOnly = (firstPage, secondPage) => + feedbackCount(firstPage) === 1 && feedbackCount(secondPage) === 0 + /** Read as "no cursor", the empty string puts the virtual tool on both pages. */ function assertEmptyCursor(label, firstPage, secondPage) { check( `${label} · empty-string cursor · send_feedback is listed once, on the first page`, - feedbackCount(firstPage) === 1 && feedbackCount(secondPage) === 0, + feedbackOnFirstPageOnly(firstPage, secondPage), JSON.stringify({ pageOne: firstPage?.tools?.map((t) => t.name), pageTwo: secondPage?.tools?.map((t) => t.name), @@ -119,7 +123,7 @@ function assertEnumeration(label, firstPage, secondPage, callText, feedbackCallT ) check( `${label} · send_feedback appears only on the first page, once`, - feedbackCount(firstPage) === 1 && feedbackCount(secondPage) === 0, + feedbackOnFirstPageOnly(firstPage, secondPage), JSON.stringify({ pageOne: feedbackCount(firstPage), pageTwo: feedbackCount(secondPage) }) ) check( @@ -137,7 +141,7 @@ function assertEnumeration(label, firstPage, secondPage, callText, feedbackCallT function assertCollision(label, pages, feedbackCallText) { check( `${label} · colliding catalogue · only the real send_feedback is listed, on the first page`, - feedbackCount(pages[0]) === 1 && feedbackCount(pages[1]) === 0, + feedbackOnFirstPageOnly(pages[0], pages[1]), JSON.stringify(pages.map((page) => page?.tools?.map((t) => t.name))) ) check( diff --git a/packages/mcp/src/__tests__/tools-list-envelope.test.ts b/packages/mcp/src/__tests__/tools-list-envelope.test.ts index 96c9d38ef4..ea78947260 100644 --- a/packages/mcp/src/__tests__/tools-list-envelope.test.ts +++ b/packages/mcp/src/__tests__/tools-list-envelope.test.ts @@ -27,16 +27,20 @@ const PAGE_TWO = [{ name: 'page_two_tool', description: 'On page two', inputSche * enumeration. Pass `cursorToken: ''` to paginate by the empty-string cursor. */ function setupPaginatedServer( - listResponses?: { firstPage?: Record; secondPage?: Record }, - cursorToken = 'page-2' + options: { + firstPage?: Record + secondPage?: Record + cursorToken?: string + } = {} ) { + const { firstPage, secondPage, cursorToken = 'page-2' } = options const server = new Server({ name: 'paginated test', version: '1.0.0' }, { capabilities: { tools: {} } }) server.setRequestHandler(ListToolsRequestSchema, async (request) => { if (request.params?.cursor === cursorToken) { - return listResponses?.secondPage ?? { tools: PAGE_TWO } + return secondPage ?? { tools: PAGE_TWO } } - return listResponses?.firstPage ?? { tools: PAGE_ONE, nextCursor: cursorToken } + return firstPage ?? { tools: PAGE_ONE, nextCursor: cursorToken } }) server.setRequestHandler(CallToolRequestSchema, async (request) => ({ @@ -295,7 +299,7 @@ describe('tools/list response envelope', () => { // Read as "no cursor", `''` puts the virtual tool on this page as well as // the first, so a client concatenating the pages sees it twice. it('treats an empty-string cursor as a continuation page, not the first one', async () => { - const { server, client, connect, cleanup } = setupPaginatedServer(undefined, '') + const { server, client, connect, cleanup } = setupPaginatedServer({ cursorToken: '' }) try { instrument(server, fakePostHog(), { reportMissing: false, collectFeedback: true }) await connect() From c5892de0789039ed93be22ab21d1a2954b3e89b1 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Tue, 15 Sep 2026 14:19:49 +0300 Subject: [PATCH 4/7] fix(mcp): inject get_more_tools once, on the first tools/list page Give the missing-capability virtual tool the same first-page rule as send_feedback: append on the cursorless request only, let a real first-page owner win with a warning, and warn when a later page reveals a shadowed real owner. The shared isFirstPage constant is hoisted above both virtual-tool blocks so they cannot drift. Generated-By: PostHog Desktop Task-Id: 3b751ad5-c551-4ad2-b90b-e50438622b88 --- .changeset/mcp-get-more-tools-pagination.md | 5 ++ packages/mcp/README.md | 11 ++-- .../src/__tests__/tools-list-envelope.test.ts | 61 +++++++++++++++++++ .../mcp/src/extensions/instrumentation.ts | 21 ++++--- 4 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 .changeset/mcp-get-more-tools-pagination.md diff --git a/.changeset/mcp-get-more-tools-pagination.md b/.changeset/mcp-get-more-tools-pagination.md new file mode 100644 index 0000000000..cbde87ca13 --- /dev/null +++ b/.changeset/mcp-get-more-tools-pagination.md @@ -0,0 +1,5 @@ +--- +'@posthog/mcp': patch +--- + +Inject the get_more_tools tool only on the first tools/list page, matching send_feedback: a paginated catalogue's concatenated listing carries it once instead of once per page. A real first-page tool with the same name still wins (warning logged); a real tool on a later page is shadowed, with a warning when a client fetches that page — rename the SDK's tool with the `missingCapabilityToolName` option if your catalogue uses the name. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 3d1e3e44a4..727b8c4601 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -283,11 +283,12 @@ both advertises both tools. Like `get_more_tools`, a real tool that already uses wins: the SDK warns, skips injection, and delegates calls to the real handler. On a paginated catalogue (a `tools/list` response with a `nextCursor`), `instrument()` injects -`send_feedback` on the first page only — the page every client reads, including clients that never -follow `nextCursor` — so a compliant client's concatenated list carries it once. "First page" means -a `tools/list` request with no cursor; an empty string is a valid cursor, so `cursor: ""` is a -continuation page. Hosts using `prepareToolList()` directly own this rule themselves: pass -`collectFeedback: true` only for the first page. +its virtual tools (`send_feedback` and `get_more_tools`) on the first page only — the page every +client reads, including clients that never follow `nextCursor` — so a compliant client's +concatenated list carries each once. "First page" means a `tools/list` request with no cursor; an +empty string is a valid cursor, so `cursor: ""` is a continuation page. Hosts using +`prepareToolList()` directly own this rule themselves: pass `collectFeedback: true` only for the +first page. Name collisions are detected on the first page only. A real tool named `send_feedback` (or `get_more_tools`) on the first page wins: the SDK warns, skips injection, and forwards its calls. A diff --git a/packages/mcp/src/__tests__/tools-list-envelope.test.ts b/packages/mcp/src/__tests__/tools-list-envelope.test.ts index ea78947260..dc86d63bbf 100644 --- a/packages/mcp/src/__tests__/tools-list-envelope.test.ts +++ b/packages/mcp/src/__tests__/tools-list-envelope.test.ts @@ -373,4 +373,65 @@ describe('tools/list response envelope', () => { } }) }) + + /** The same first-page rule, on the missing-capability virtual tool. */ + describe('get_more_tools on a paginated catalogue', () => { + const GET_MORE_TOOLS = 'get_more_tools' + const REAL_MISSING_TOOL = { + name: GET_MORE_TOOLS, + description: 'A real application tool that owns the name', + inputSchema: { type: 'object' as const }, + } + + it('appends the virtual tool only to the first page', async () => { + const { server, client, connect, cleanup } = setupPaginatedServer() + try { + instrument(server, fakePostHog(), { reportMissing: true }) + await connect() + + const firstPage = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) + const secondPage = await client.request( + { method: 'tools/list', params: { cursor: 'page-2' } }, + ListToolsResultSchema + ) + expect(firstPage.tools.map((tool) => tool.name)).toEqual(['page_one_tool', GET_MORE_TOOLS]) + expect(secondPage.tools.map((tool) => tool.name)).toEqual(['page_two_tool']) + } finally { + await cleanup() + } + }) + + it('a real owner on a later page is shadowed, with a warning when its page is served', async () => { + const { server, client, connect, cleanup } = setupPaginatedServer({ + secondPage: { tools: [REAL_MISSING_TOOL] }, + }) + const warnings: string[] = [] + try { + instrument(server, fakePostHog(), { + reportMissing: true, + logger: (message: string) => warnings.push(message), + }) + await connect() + + const firstPage = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) + const secondPage = await client.request( + { method: 'tools/list', params: { cursor: 'page-2' } }, + ListToolsResultSchema + ) + expect(firstPage.tools.map((tool) => tool.name)).toEqual(['page_one_tool', GET_MORE_TOOLS]) + expect(secondPage.tools.map((tool) => tool.name)).toEqual([GET_MORE_TOOLS]) + expect(warnings.some((message) => message.includes('is shadowed by the SDK'))).toBe(true) + + // Calls to the name go to the SDK, not the real tool. + const result = await client.request( + { method: 'tools/call', params: { name: GET_MORE_TOOLS, arguments: { context: 'need bulk delete' } } }, + CallToolResultSchema + ) + expect((result.content as { text: string }[])[0].text).not.toBe(`called: ${GET_MORE_TOOLS}`) + expect(eventCapture.findEventByType(MCPAnalyticsEventType.mcpMissingCapability)).toBeDefined() + } finally { + await cleanup() + } + }) + }) }) diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index bb981b891b..294ff2b935 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -825,19 +825,29 @@ async function getTracedToolsList( } if (data) { + // A compliant client concatenates every page into one list, so only the + // first page — the one every client reads, including clients that never + // follow `nextCursor` — may carry a virtual tool. Presence, not + // truthiness: `cursor: ""` is a continuation page. + const isFirstPage = request.params?.cursor == null + const missingToolName = resolveMissingCapabilityToolName(data.options) if (data.options.reportMissing) { const alreadyPresent = tools.some((tool) => tool?.name === missingToolName) - if (alreadyPresent) { + if (isFirstPage && alreadyPresent) { data.logger( - `Warning: Cannot inject missing-capability tool "${missingToolName}" because a real tool already uses that name. The real tool will not be intercepted.` + `Warning: Cannot inject missing-capability tool "${missingToolName}" because a real tool already uses that name. The real tool will not be intercepted. To keep missing-capability reports, rename the SDK's tool with the missingCapabilityToolName option.` ) - } else { + } else if (isFirstPage) { const virtualTool = getReportMissingToolDescriptor(missingToolName) tools.push(virtualTool) // Cached separately because the virtual tool is added after the listing // was cached, and its calls need ownership like any other tool's. cacheToolAnalyticsParameterOwnership(data.toolAnalyticsParameterOwnership, [virtualTool]) + } else if (alreadyPresent) { + data.logger( + `Warning: A real tool "${missingToolName}" on a later tools/list page is shadowed by the SDK's missing-capability tool. Its calls will be intercepted. Rename the SDK's tool with the missingCapabilityToolName option to keep both.` + ) } } @@ -845,11 +855,6 @@ async function getTracedToolsList( if (feedbackOptions) { const feedbackToolName = feedbackOptions.toolName ?? SEND_FEEDBACK_TOOL_NAME const alreadyPresent = tools.some((tool) => tool?.name === feedbackToolName) - // A compliant client concatenates every page into one list, so only the - // first page — the one every client reads, including clients that never - // follow `nextCursor` — may carry the virtual tool. Presence, not - // truthiness: `cursor: ""` is a continuation page. - const isFirstPage = request.params?.cursor == null if (isFirstPage && alreadyPresent) { data.logger( `Warning: Cannot inject agent-feedback tool "${feedbackToolName}" because a real tool already uses that name. The real tool will not be intercepted. To collect feedback alongside it, rename the SDK's tool with collectFeedback: { toolName: "..." }.` From b81e6a765b662755a1b4b3d13f8a2fd227b5d8b6 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Tue, 15 Sep 2026 14:21:59 +0300 Subject: [PATCH 5/7] docs: drop the send_feedback comparison from the changeset Generated-By: PostHog Desktop Task-Id: 3b751ad5-c551-4ad2-b90b-e50438622b88 --- .changeset/mcp-get-more-tools-pagination.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mcp-get-more-tools-pagination.md b/.changeset/mcp-get-more-tools-pagination.md index cbde87ca13..fbc0f68ed4 100644 --- a/.changeset/mcp-get-more-tools-pagination.md +++ b/.changeset/mcp-get-more-tools-pagination.md @@ -2,4 +2,4 @@ '@posthog/mcp': patch --- -Inject the get_more_tools tool only on the first tools/list page, matching send_feedback: a paginated catalogue's concatenated listing carries it once instead of once per page. A real first-page tool with the same name still wins (warning logged); a real tool on a later page is shadowed, with a warning when a client fetches that page — rename the SDK's tool with the `missingCapabilityToolName` option if your catalogue uses the name. +Inject the get_more_tools tool only on the first tools/list page, so a paginated catalogue's concatenated listing carries it once instead of once per page. A real first-page tool with the same name still wins (warning logged); a real tool on a later page is shadowed, with a warning when a client fetches that page — rename the SDK's tool with the `missingCapabilityToolName` option if your catalogue uses the name. From 9c24a562264a36b1b330f2f5436e4e0a00012d55 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Tue, 15 Sep 2026 14:29:09 +0300 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20address=20qa-swarm=20nits=20?= =?UTF-8?q?=E2=80=94=20comment=20parity=20and=20shared=20tool-name=20const?= =?UTF-8?q?ant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also completes the direct-dispatcher pagination guidance in the README: prepareToolList() injects get_more_tools whenever reportMissing is true with no first-page gating of its own, same as collectFeedback, so the guidance needs to call out both options. Generated-By: PostHog Desktop Task-Id: 3b751ad5-c551-4ad2-b90b-e50438622b88 --- packages/mcp/README.md | 4 ++-- .../mcp/src/__tests__/tools-list-envelope.test.ts | 14 +++++++------- packages/mcp/src/extensions/instrumentation.ts | 3 +++ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 727b8c4601..f2fb3f2a2a 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -287,8 +287,8 @@ its virtual tools (`send_feedback` and `get_more_tools`) on the first page only client reads, including clients that never follow `nextCursor` — so a compliant client's concatenated list carries each once. "First page" means a `tools/list` request with no cursor; an empty string is a valid cursor, so `cursor: ""` is a continuation page. Hosts using -`prepareToolList()` directly own this rule themselves: pass `collectFeedback: true` only for the -first page. +`prepareToolList()` directly own this rule themselves: pass `reportMissing: true` and +`collectFeedback: true` only for the first page. Name collisions are detected on the first page only. A real tool named `send_feedback` (or `get_more_tools`) on the first page wins: the SDK warns, skips injection, and forwards its calls. A diff --git a/packages/mcp/src/__tests__/tools-list-envelope.test.ts b/packages/mcp/src/__tests__/tools-list-envelope.test.ts index dc86d63bbf..365bf0c231 100644 --- a/packages/mcp/src/__tests__/tools-list-envelope.test.ts +++ b/packages/mcp/src/__tests__/tools-list-envelope.test.ts @@ -10,6 +10,7 @@ import { import { instrument } from '../index' import { MCPAnalyticsEventType } from '../extensions/event-types' import { SEND_FEEDBACK_TOOL_NAME } from '../extensions/feedback' +import { GET_MORE_TOOLS_NAME } from '../extensions/tools' import { EventCapture, fakePostHog } from './test-utils' /** @@ -376,9 +377,8 @@ describe('tools/list response envelope', () => { /** The same first-page rule, on the missing-capability virtual tool. */ describe('get_more_tools on a paginated catalogue', () => { - const GET_MORE_TOOLS = 'get_more_tools' const REAL_MISSING_TOOL = { - name: GET_MORE_TOOLS, + name: GET_MORE_TOOLS_NAME, description: 'A real application tool that owns the name', inputSchema: { type: 'object' as const }, } @@ -394,7 +394,7 @@ describe('tools/list response envelope', () => { { method: 'tools/list', params: { cursor: 'page-2' } }, ListToolsResultSchema ) - expect(firstPage.tools.map((tool) => tool.name)).toEqual(['page_one_tool', GET_MORE_TOOLS]) + expect(firstPage.tools.map((tool) => tool.name)).toEqual(['page_one_tool', GET_MORE_TOOLS_NAME]) expect(secondPage.tools.map((tool) => tool.name)).toEqual(['page_two_tool']) } finally { await cleanup() @@ -418,16 +418,16 @@ describe('tools/list response envelope', () => { { method: 'tools/list', params: { cursor: 'page-2' } }, ListToolsResultSchema ) - expect(firstPage.tools.map((tool) => tool.name)).toEqual(['page_one_tool', GET_MORE_TOOLS]) - expect(secondPage.tools.map((tool) => tool.name)).toEqual([GET_MORE_TOOLS]) + expect(firstPage.tools.map((tool) => tool.name)).toEqual(['page_one_tool', GET_MORE_TOOLS_NAME]) + expect(secondPage.tools.map((tool) => tool.name)).toEqual([GET_MORE_TOOLS_NAME]) expect(warnings.some((message) => message.includes('is shadowed by the SDK'))).toBe(true) // Calls to the name go to the SDK, not the real tool. const result = await client.request( - { method: 'tools/call', params: { name: GET_MORE_TOOLS, arguments: { context: 'need bulk delete' } } }, + { method: 'tools/call', params: { name: GET_MORE_TOOLS_NAME, arguments: { context: 'need bulk delete' } } }, CallToolResultSchema ) - expect((result.content as { text: string }[])[0].text).not.toBe(`called: ${GET_MORE_TOOLS}`) + expect((result.content as { text: string }[])[0].text).not.toBe(`called: ${GET_MORE_TOOLS_NAME}`) expect(eventCapture.findEventByType(MCPAnalyticsEventType.mcpMissingCapability)).toBeDefined() } finally { await cleanup() diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index 294ff2b935..e06e14480b 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -845,6 +845,9 @@ async function getTracedToolsList( // was cached, and its calls need ownership like any other tool's. cacheToolAnalyticsParameterOwnership(data.toolAnalyticsParameterOwnership, [virtualTool]) } else if (alreadyPresent) { + // Conflicts are only detected on the pages a client actually + // fetches; a real owner here is already shadowed by the first-page + // injection, so the host must rename the SDK's tool. data.logger( `Warning: A real tool "${missingToolName}" on a later tools/list page is shadowed by the SDK's missing-capability tool. Its calls will be intercepted. Rename the SDK's tool with the missingCapabilityToolName option to keep both.` ) From 003dbab61d12df598859cd90e5a7f76aa815f43c Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Tue, 15 Sep 2026 14:34:58 +0300 Subject: [PATCH 7/7] refactor: apply simplify pass Hoist the listBothPages pagination-walk helper to file scope so the get_more_tools tests reuse it instead of inlining the two requests. Generated-By: PostHog Desktop Task-Id: 3b751ad5-c551-4ad2-b90b-e50438622b88 --- .../src/__tests__/tools-list-envelope.test.ts | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/mcp/src/__tests__/tools-list-envelope.test.ts b/packages/mcp/src/__tests__/tools-list-envelope.test.ts index 365bf0c231..f6faeff52a 100644 --- a/packages/mcp/src/__tests__/tools-list-envelope.test.ts +++ b/packages/mcp/src/__tests__/tools-list-envelope.test.ts @@ -64,6 +64,16 @@ function setupPaginatedServer( } } +/** Walk the whole enumeration, returning the names each page advertised. */ +const listBothPages = async (client: Client, cursorToken = 'page-2'): Promise<[string[], string[]]> => { + const firstPage = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) + const secondPage = await client.request( + { method: 'tools/list', params: { cursor: cursorToken } }, + ListToolsResultSchema + ) + return [firstPage.tools.map((tool) => tool.name), secondPage.tools.map((tool) => tool.name)] +} + describe('tools/list response envelope', () => { let eventCapture: EventCapture @@ -258,16 +268,6 @@ describe('tools/list response envelope', () => { inputSchema: { type: 'object' as const }, } - /** Walk the whole enumeration, returning the names each page advertised. */ - const listBothPages = async (client: Client, cursorToken = 'page-2'): Promise<[string[], string[]]> => { - const firstPage = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) - const secondPage = await client.request( - { method: 'tools/list', params: { cursor: cursorToken } }, - ListToolsResultSchema - ) - return [firstPage.tools.map((tool) => tool.name), secondPage.tools.map((tool) => tool.name)] - } - it('appends the virtual tool only to the first page', async () => { const { server, client, connect, cleanup } = await setupPaginatedServer() try { @@ -389,13 +389,9 @@ describe('tools/list response envelope', () => { instrument(server, fakePostHog(), { reportMissing: true }) await connect() - const firstPage = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) - const secondPage = await client.request( - { method: 'tools/list', params: { cursor: 'page-2' } }, - ListToolsResultSchema - ) - expect(firstPage.tools.map((tool) => tool.name)).toEqual(['page_one_tool', GET_MORE_TOOLS_NAME]) - expect(secondPage.tools.map((tool) => tool.name)).toEqual(['page_two_tool']) + const [pageOne, pageTwo] = await listBothPages(client) + expect(pageOne).toEqual(['page_one_tool', GET_MORE_TOOLS_NAME]) + expect(pageTwo).toEqual(['page_two_tool']) } finally { await cleanup() } @@ -413,13 +409,9 @@ describe('tools/list response envelope', () => { }) await connect() - const firstPage = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) - const secondPage = await client.request( - { method: 'tools/list', params: { cursor: 'page-2' } }, - ListToolsResultSchema - ) - expect(firstPage.tools.map((tool) => tool.name)).toEqual(['page_one_tool', GET_MORE_TOOLS_NAME]) - expect(secondPage.tools.map((tool) => tool.name)).toEqual([GET_MORE_TOOLS_NAME]) + const [pageOne, pageTwo] = await listBothPages(client) + expect(pageOne).toEqual(['page_one_tool', GET_MORE_TOOLS_NAME]) + expect(pageTwo).toEqual([GET_MORE_TOOLS_NAME]) expect(warnings.some((message) => message.includes('is shadowed by the SDK'))).toBe(true) // Calls to the name go to the SDK, not the real tool.