Skip to content

@posthog/mcp: instrument() silently no-ops on MCP TypeScript SDK v2 servers (string setRequestHandler registrations are never wrapped) #4449

Description

@OrbitingBucket

Environment

  • @posthog/mcp 0.10.7
  • @modelcontextprotocol/server 2.0.0 (the new modular SDK v2, stable)
  • Server framework: @rekog/mcp-nest 2.0.0 (NestJS microservice strategy; stateless streamable HTTP — a fresh McpServer per request, handlers bound after serverMutator)
  • Node 26

Symptom

instrument(server, posthog, options) produces zero telemetry on SDK v2 servers, silently. Because instrument() catches internally and returns a no-op handle, nothing throws — the integration looks healthy while $mcp_* events stop entirely. For a team relying on MCP analytics for adoption reporting, this failure mode is invisible until someone notices flat dashboards.

Three composition defects (in order encountered)

  1. High-level compat check requires the removed .tool() method. isCompatibleServerType demands typeof server.tool === "function" for any object with a .server property. SDK v2's McpServer only has registerTool(), so the high-level entry throws (internally). The low-level server.server passes every low-level check (setRequestHandler, _requestHandlers Map, getClientVersion, _serverInfo) — so the gap is narrow.

  2. The setRequestHandler patch only recognizes Zod-schema registrations. patchRequestHandlers derives the handler name via getObjectShape(requestSchema) + getLiteralValue(shape.method). SDK v2 (and frameworks built on it) register handlers with string method names: server.setRequestHandler("tools/call", handler). Those fall through unwrapped — so any handler registered after instrument() (the normal order for per-request/stateless servers, where instrumentation runs in a server-mutator hook before handlers are bound) silently replaces the analytics wrapper. Result: no $mcp_tool_call, no $mcp_tools_list, no intent injection.

  3. Context/intent ownership is only learned in the tools/list wrapper. cacheToolAnalyticsParameterOwnership runs inside the wrapped tools/list. A stateless server instance serves exactly one request, so a tools/call never shares an instance with a tools/listanalyticsOwnsContext is always false → the context argument is never captured as $mcp_intent (and never stripped from the downstream request), even when wrapping works.

Validated fix (small)

Attached dist-level patch (~10 changed lines, both build variants) that we validated end-to-end through a real NestJS + SDK v2 server (supertest e2e: $mcp_tool_call captured with $mcp_tool_name, $mcp_intent from the injected context parameter, and identified distinct_id):

  1. In the patched setRequestHandler, accept string method names:
    const handlerName = typeof requestSchema === "string" ? requestSchema : shape?.method ? getLiteralValue(shape.method) : undefined;
  2. In captureToolCall, when data.toolAnalyticsParameterOwnership has no entry for the called tool, lazily resolve ownership by invoking the remembered original tools/list handler (same mechanism isToolAdvertised already uses) and running cacheToolAnalyticsParameterOwnership on the result.

Relaxing the .tool() requirement in isCompatibleServerType (accept registerTool as the high-level marker) would additionally make the documented instrument(mcpServer) / instrumentMutator usage work on SDK v2 instead of requiring callers to pass server.server.

Docs note for identify()

Under SDK v2 dispatch the handler extra no longer carries requestInfo; the HTTP request surfaces at extra.http.req as a web-standard Request (headers via .headers.get("authorization")). identify() implementations written against the v1 extra.requestInfo.headers shape silently return null (anonymous events). Worth documenting alongside v2 support.

Repro (minimal)

import { McpServer } from "@modelcontextprotocol/server";
import { instrument } from "@posthog/mcp";

const server = new McpServer(
  { name: "repro", version: "1.0.0" },
  { capabilities: { tools: { listChanged: true } } },
);
const low = server.server;

instrument(low, posthog, { context: { description: "goal" } }); // low-level entry: compat passes
// Framework binds handlers AFTER instrument, with string method names (SDK v2 style):
low.setRequestHandler("tools/list", () => ({ tools: [{ name: "t", description: "d", inputSchema: { type: "object" } }] }));
low.setRequestHandler("tools/call", async () => ({ content: [{ type: "text", text: "{}" }] }));
// -> wrappers replaced; tools/list advertises NO injected context param; tools/call captures nothing.
// instrument(server, ...) (high-level, as documented) throws internally and no-ops entirely.

Validated patch (dist-level, against 0.10.7)

Happy to port this to the TypeScript source in packages/mcp and open a PR if useful.

Cumulative diff vs pristine 0.10.7 (both build variants)
--- /private/tmp/claude-503/-Users-andreyzaytsev-dev-worktrees-platform-mcp-nest-v2-migration/5ce1d94f-66e6-4de7-9d06-daa14c4800ce/scratchpad/package/dist/extensions/instrumentation.js	1985-10-26 09:15:00
+++ node_modules/@posthog/mcp/dist/extensions/instrumentation.js	2026-08-06 14:38:28
@@ -51,6 +51,19 @@
 async function captureToolCall(params) {
     const { server, data, request, extra, execute, parameterOwnership, eventType, explicitContextIntent, takeCapturedError } = params;
     const resolvedEventType = eventType ?? external_event_types_js_namespaceObject.MCPAnalyticsEventType.mcpToolsCall;
+    const lazyToolName = request.params?.name;
+    if (lazyToolName && !data.toolAnalyticsParameterOwnership.has(lazyToolName)) {
+        const lazyListHandler = originalRequestHandlers.get(server)?.get('tools/list');
+        if (lazyListHandler) try {
+            const lazyList = await lazyListHandler({
+                method: 'tools/list',
+                params: {}
+            }, extra);
+            if (lazyList && Array.isArray(lazyList.tools)) cacheToolAnalyticsParameterOwnership(data.toolAnalyticsParameterOwnership, lazyList.tools);
+        } catch (lazyError) {
+            data.logger(`Warning: PostHog MCP analytics could not resolve context ownership for "${lazyToolName}" - ${lazyError}`);
+        }
+    }
     const ownership = getActiveAnalyticsParameterOwnership(data, request.params?.name, parameterOwnership, resolvedEventType === external_event_types_js_namespaceObject.MCPAnalyticsEventType.mcpMissingCapability);
     const conversation = (0, external_conversation_id_js_namespaceObject.resolveConversationId)(ownership.conversationId, request.params?.arguments, request.params?.name, resolvedEventType === external_event_types_js_namespaceObject.MCPAnalyticsEventType.mcpMissingCapability ? (0, external_tools_js_namespaceObject.resolveMissingCapabilityToolName)(data.options) : '');
     const downstreamRequest = cloneRequestWithoutOwnedAnalyticsArguments(request, ownership);
@@ -171,7 +184,7 @@
     const originalSetRequestHandler = server.setRequestHandler.bind(server);
     server.setRequestHandler = (requestSchema, originalHandler)=>{
         const shape = (0, external_mcp_sdk_compat_js_namespaceObject.getObjectShape)(requestSchema);
-        const handlerName = shape?.method ? (0, external_mcp_sdk_compat_js_namespaceObject.getLiteralValue)(shape.method) : void 0;
+        const handlerName = 'string' == typeof requestSchema ? requestSchema : shape?.method ? (0, external_mcp_sdk_compat_js_namespaceObject.getLiteralValue)(shape.method) : void 0;
         const patch = 'string' == typeof handlerName ? patches[handlerName] : void 0;
         if (!patch || 'string' != typeof handlerName) return originalSetRequestHandler(requestSchema, originalHandler);
         const result = originalSetRequestHandler(requestSchema, originalHandler);
--- /private/tmp/claude-503/-Users-andreyzaytsev-dev-worktrees-platform-mcp-nest-v2-migration/5ce1d94f-66e6-4de7-9d06-daa14c4800ce/scratchpad/package/dist/extensions/instrumentation.mjs	1985-10-26 09:15:00
+++ node_modules/@posthog/mcp/dist/extensions/instrumentation.mjs	2026-08-06 14:38:28
@@ -16,6 +16,19 @@
 async function captureToolCall(params) {
     const { server, data, request, extra, execute, parameterOwnership, eventType, explicitContextIntent, takeCapturedError } = params;
     const resolvedEventType = eventType ?? MCPAnalyticsEventType.mcpToolsCall;
+    const lazyToolName = request.params?.name;
+    if (lazyToolName && !data.toolAnalyticsParameterOwnership.has(lazyToolName)) {
+        const lazyListHandler = originalRequestHandlers.get(server)?.get('tools/list');
+        if (lazyListHandler) try {
+            const lazyList = await lazyListHandler({
+                method: 'tools/list',
+                params: {}
+            }, extra);
+            if (lazyList && Array.isArray(lazyList.tools)) cacheToolAnalyticsParameterOwnership(data.toolAnalyticsParameterOwnership, lazyList.tools);
+        } catch (lazyError) {
+            data.logger(`Warning: PostHog MCP analytics could not resolve context ownership for "${lazyToolName}" - ${lazyError}`);
+        }
+    }
     const ownership = getActiveAnalyticsParameterOwnership(data, request.params?.name, parameterOwnership, resolvedEventType === MCPAnalyticsEventType.mcpMissingCapability);
     const conversation = resolveConversationId(ownership.conversationId, request.params?.arguments, request.params?.name, resolvedEventType === MCPAnalyticsEventType.mcpMissingCapability ? resolveMissingCapabilityToolName(data.options) : '');
     const downstreamRequest = cloneRequestWithoutOwnedAnalyticsArguments(request, ownership);
@@ -136,7 +149,7 @@
     const originalSetRequestHandler = server.setRequestHandler.bind(server);
     server.setRequestHandler = (requestSchema, originalHandler)=>{
         const shape = getObjectShape(requestSchema);
-        const handlerName = shape?.method ? getLiteralValue(shape.method) : void 0;
+        const handlerName = 'string' == typeof requestSchema ? requestSchema : shape?.method ? getLiteralValue(shape.method) : void 0;
         const patch = 'string' == typeof handlerName ? patches[handlerName] : void 0;
         if (!patch || 'string' != typeof handlerName) return originalSetRequestHandler(requestSchema, originalHandler);
         const result = originalSetRequestHandler(requestSchema, originalHandler);

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions