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)
-
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.
-
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.
-
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/list → analyticsOwnsContext 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):
- In the patched
setRequestHandler, accept string method names:
const handlerName = typeof requestSchema === "string" ? requestSchema : shape?.method ? getLiteralValue(shape.method) : undefined;
- 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);
Environment
@posthog/mcp0.10.7@modelcontextprotocol/server2.0.0 (the new modular SDK v2, stable)@rekog/mcp-nest2.0.0 (NestJS microservice strategy; stateless streamable HTTP — a freshMcpServerper request, handlers bound afterserverMutator)Symptom
instrument(server, posthog, options)produces zero telemetry on SDK v2 servers, silently. Becauseinstrument()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)
High-level compat check requires the removed
.tool()method.isCompatibleServerTypedemandstypeof server.tool === "function"for any object with a.serverproperty. SDK v2'sMcpServeronly hasregisterTool(), so the high-level entry throws (internally). The low-levelserver.serverpasses every low-level check (setRequestHandler,_requestHandlersMap,getClientVersion,_serverInfo) — so the gap is narrow.The
setRequestHandlerpatch only recognizes Zod-schema registrations.patchRequestHandlersderives the handler name viagetObjectShape(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 afterinstrument()(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.Context/intent ownership is only learned in the tools/list wrapper.
cacheToolAnalyticsParameterOwnershipruns inside the wrappedtools/list. A stateless server instance serves exactly one request, so atools/callnever shares an instance with atools/list→analyticsOwnsContextis always false → thecontextargument 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_callcaptured with$mcp_tool_name,$mcp_intentfrom the injected context parameter, and identifieddistinct_id):setRequestHandler, accept string method names:const handlerName = typeof requestSchema === "string" ? requestSchema : shape?.method ? getLiteralValue(shape.method) : undefined;captureToolCall, whendata.toolAnalyticsParameterOwnershiphas no entry for the called tool, lazily resolve ownership by invoking the remembered originaltools/listhandler (same mechanismisToolAdvertisedalready uses) and runningcacheToolAnalyticsParameterOwnershipon the result.Relaxing the
.tool()requirement inisCompatibleServerType(acceptregisterToolas the high-level marker) would additionally make the documentedinstrument(mcpServer)/instrumentMutatorusage work on SDK v2 instead of requiring callers to passserver.server.Docs note for identify()
Under SDK v2 dispatch the handler
extrano longer carriesrequestInfo; the HTTP request surfaces atextra.http.reqas a web-standardRequest(headers via.headers.get("authorization")).identify()implementations written against the v1extra.requestInfo.headersshape silently return null (anonymous events). Worth documenting alongside v2 support.Repro (minimal)
Validated patch (dist-level, against 0.10.7)
Happy to port this to the TypeScript source in
packages/mcpand open a PR if useful.Cumulative diff vs pristine 0.10.7 (both build variants)