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..dbb8c08541 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,43 @@ 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 + +/** 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`, + feedbackOnFirstPageOnly(firstPage, secondPage), + 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 +121,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`, + feedbackOnFirstPageOnly(firstPage, secondPage), + 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`, + feedbackOnFirstPageOnly(pages[0], pages[1]), + 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 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 clientTransport.close?.() - await serverTransport.close?.() + 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 +252,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 +279,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 +318,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) + assertEnumeration('v2', first.result, second.result, call.text, feedbackCall.text) } finally { - http.close() - await sleep(150) + 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) + assertCollision('v2', [first.result, second.result], feedbackCall.text) + } finally { + 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 +358,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..ea78947260 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,25 @@ 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( + 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 === 'page-2') { - return { tools: PAGE_TWO } + if (request.params?.cursor === cursorToken) { + return secondPage ?? { tools: PAGE_TWO } } - return listResponses?.firstPage ?? { tools: PAGE_ONE, nextCursor: 'page-2' } + return firstPage ?? { tools: PAGE_ONE, nextCursor: cursorToken } }) server.setRequestHandler(CallToolRequestSchema, async (request) => ({ @@ -227,4 +238,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({ cursorToken: '' }) + 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..bb981b891b 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -845,14 +845,26 @@ 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. + 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.` + `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.` + ) } }