@posthog/mcp 0.10.9 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extensions/capture.d.ts.map +1 -1
- package/dist/extensions/capture.js +2 -0
- package/dist/extensions/capture.mjs +2 -0
- package/dist/extensions/compatibility.d.ts.map +1 -1
- package/dist/extensions/constants.d.ts +2 -0
- package/dist/extensions/constants.d.ts.map +1 -1
- package/dist/extensions/constants.js +3 -1
- package/dist/extensions/constants.mjs +3 -1
- package/dist/extensions/context-parameters.d.ts.map +1 -1
- package/dist/extensions/conversation-id.d.ts +2 -13
- package/dist/extensions/conversation-id.d.ts.map +1 -1
- package/dist/extensions/headers.d.ts +33 -0
- package/dist/extensions/headers.d.ts.map +1 -0
- package/dist/extensions/headers.js +54 -0
- package/dist/extensions/headers.mjs +17 -0
- package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
- package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
- package/dist/extensions/instrumentation.d.ts +7 -0
- package/dist/extensions/instrumentation.d.ts.map +1 -1
- package/dist/extensions/instrumentation.js +4 -0
- package/dist/extensions/instrumentation.mjs +4 -0
- package/dist/extensions/internal.d.ts.map +1 -1
- package/dist/extensions/internal.js +2 -0
- package/dist/extensions/internal.mjs +2 -0
- package/dist/extensions/output-instructions.d.ts +6 -8
- package/dist/extensions/output-instructions.d.ts.map +1 -1
- package/dist/extensions/posthog-events.js +4 -0
- package/dist/extensions/posthog-events.mjs +4 -0
- package/dist/extensions/posthog-mcp.d.ts.map +1 -1
- package/dist/extensions/posthog-mcp.js +3 -0
- package/dist/extensions/posthog-mcp.mjs +3 -0
- package/dist/extensions/session-token.d.ts.map +1 -1
- package/dist/extensions/session-token.js +5 -14
- package/dist/extensions/session-token.mjs +2 -11
- package/dist/extensions/session.d.ts +4 -3
- package/dist/extensions/session.d.ts.map +1 -1
- package/dist/extensions/sink.d.ts +4 -6
- package/dist/extensions/sink.d.ts.map +1 -1
- package/dist/extensions/tools.d.ts.map +1 -1
- package/dist/extensions/transport-identity.d.ts +59 -0
- package/dist/extensions/transport-identity.d.ts.map +1 -0
- package/dist/extensions/transport-identity.js +63 -0
- package/dist/extensions/transport-identity.mjs +20 -0
- package/dist/extensions/truncation.d.ts.map +1 -1
- package/dist/extensions/truncation.js +2 -0
- package/dist/extensions/truncation.mjs +2 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.mjs +1 -1
- package/package.json +1 -1
- package/src/extensions/capture.ts +6 -0
- package/src/extensions/compatibility.ts +0 -9
- package/src/extensions/constants.ts +2 -0
- package/src/extensions/context-parameters.ts +3 -11
- package/src/extensions/conversation-id.ts +5 -17
- package/src/extensions/headers.ts +52 -0
- package/src/extensions/instrument-highlevel.ts +0 -1
- package/src/extensions/instrument-lowlevel.ts +0 -1
- package/src/extensions/instrumentation.ts +29 -37
- package/src/extensions/internal.ts +6 -5
- package/src/extensions/output-instructions.ts +6 -8
- package/src/extensions/posthog-events.ts +14 -0
- package/src/extensions/posthog-mcp.ts +7 -5
- package/src/extensions/session-token.ts +3 -15
- package/src/extensions/session.ts +21 -35
- package/src/extensions/sink.ts +4 -6
- package/src/extensions/tools.ts +3 -6
- package/src/extensions/transport-identity.ts +94 -0
- package/src/extensions/truncation.ts +4 -0
- package/src/types.ts +33 -0
- package/src/version.ts +1 -1
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
resolveConversationId,
|
|
25
25
|
} from './conversation-id'
|
|
26
26
|
import { stampMetaClientInfo } from './client-identity'
|
|
27
|
+
import { stampTransportIdentity } from './transport-identity'
|
|
27
28
|
import { addInstructionsToOutputSchemas, mirrorInstructionsIntoStructuredContent } from './output-instructions'
|
|
28
29
|
import { captureEvent } from './capture'
|
|
29
30
|
import { MCPAnalyticsEventType } from './event-types'
|
|
@@ -157,22 +158,13 @@ function getActiveAnalyticsParameterOwnership(
|
|
|
157
158
|
return {
|
|
158
159
|
context: !isMissingCapabilityTool && isContextEnabled(data.options.context) && ownership?.context === true,
|
|
159
160
|
conversationId: data.options.enableConversationId === true && ownership?.conversationId === true,
|
|
160
|
-
// Deliberately read off `listed`, never the override
|
|
161
|
-
// `tools/list`
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
// mirror at all. That is the per-request server pattern — `tools/list` lands
|
|
168
|
-
// on one instance, `tools/call` on a cold one — where the handle falls back
|
|
169
|
-
// to the `content` block and a structuredContent-only client misses it.
|
|
170
|
-
//
|
|
171
|
-
// Failing closed is deliberate. Writing a key the advertised schema did not
|
|
172
|
-
// declare fails the *entire* tool result under `additionalProperties: false`,
|
|
173
|
-
// so guessing costs the caller their result, while not guessing costs us one
|
|
174
|
-
// delivery channel. The fix is a process-scoped ownership cache, so a listing
|
|
175
|
-
// served by any instance answers for the rest — not a per-call guess.
|
|
161
|
+
// Deliberately read off `listed`, never the override: only the advertised
|
|
162
|
+
// JSON Schema can say whether `tools/list` declared `_mcp_instructions` (an
|
|
163
|
+
// override is built from the live registry, which holds Zod on the
|
|
164
|
+
// high-level path). An instance that never served a listing has no answer
|
|
165
|
+
// and fails closed — writing an undeclared key fails the customer's entire
|
|
166
|
+
// tool result under `additionalProperties: false`. See ADR-0004 for the
|
|
167
|
+
// per-request-instance gap this leaves and the planned fix.
|
|
176
168
|
outputInstructions: data.options.enableConversationId === true && listed?.outputInstructions === true,
|
|
177
169
|
}
|
|
178
170
|
}
|
|
@@ -237,6 +229,9 @@ async function prepareToolCallEvent(
|
|
|
237
229
|
// `_meta` on every request rather than at `initialize`; stamp them onto this
|
|
238
230
|
// event now so concurrent requests can't cross-attribute it.
|
|
239
231
|
stampMetaClientInfo(event, request)
|
|
232
|
+
// Which *surface* of the client made this call lives only in the request
|
|
233
|
+
// headers (HTTP transports); `clientInfo` can't tell a vendor's products apart.
|
|
234
|
+
stampTransportIdentity(event, extra)
|
|
240
235
|
|
|
241
236
|
await applyResolvedMetadata(event, data, request, extra)
|
|
242
237
|
setEventIntent(event, await resolveToolCallIntent(data, request, ownership.context, extra))
|
|
@@ -250,19 +245,14 @@ async function prepareToolCallEvent(
|
|
|
250
245
|
}
|
|
251
246
|
|
|
252
247
|
/**
|
|
253
|
-
* Delivers the conversation session handle back to the agent
|
|
254
|
-
* result has:
|
|
248
|
+
* Delivers the conversation session handle back to the agent over both channels
|
|
249
|
+
* a tool result has: mirrored into `structuredContent` on every response (for
|
|
250
|
+
* tools whose output schema declares the key), and as a `content` text block on
|
|
251
|
+
* the minting response only. Why two channels and why those cadences: ADR-0004.
|
|
255
252
|
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* it back.
|
|
260
|
-
* - `content`, as a text block, only on the response that minted the session handle.
|
|
261
|
-
* Repeating it every call would put a `[SERVER]:` line in front of the user on
|
|
262
|
-
* every single tool result.
|
|
263
|
-
*
|
|
264
|
-
* If neither channel could carry a session handle we minted, the agent never received it,
|
|
265
|
-
* so clear it off the event rather than showing analytics an id nobody has.
|
|
253
|
+
* If neither channel could carry a session handle we minted, the agent never
|
|
254
|
+
* received it, so clear it off the event rather than showing analytics an id
|
|
255
|
+
* nobody has.
|
|
266
256
|
*/
|
|
267
257
|
function applyConversationInstructions(
|
|
268
258
|
event: McpEvent | null,
|
|
@@ -364,13 +354,7 @@ export type HandlerPatch = (
|
|
|
364
354
|
extra: CompatibleRequestHandlerExtra | undefined
|
|
365
355
|
) => Promise<unknown>
|
|
366
356
|
|
|
367
|
-
/**
|
|
368
|
-
* Applies the `patches` (keyed by method, e.g. `initialize`, `tools/list`) to the
|
|
369
|
-
* handlers already registered, and patches `setRequestHandler` so matching
|
|
370
|
-
* handlers registered later are patched too. The latter is what makes adapters
|
|
371
|
-
* that register handlers post-construction work — e.g. `@rekog/mcp-nest` hands a
|
|
372
|
-
* bare server to instrument() and only then registers its handlers.
|
|
373
|
-
*/
|
|
357
|
+
/** Pre-patch handlers, kept so `isToolAdvertised` can query the raw listing. */
|
|
374
358
|
const originalRequestHandlers = new WeakMap<MCPServerLike, Map<string, MCPRequestHandler>>()
|
|
375
359
|
|
|
376
360
|
function rememberOriginalRequestHandler(
|
|
@@ -417,8 +401,14 @@ export function registerFallbackRequestHandler(
|
|
|
417
401
|
server._requestHandlers.set(handlerName, (request, extra) => patch(server, fallbackHandler, request, extra))
|
|
418
402
|
}
|
|
419
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Applies the `patches` (keyed by method, e.g. `initialize`, `tools/list`) to the
|
|
406
|
+
* handlers already registered, and patches `setRequestHandler` so matching
|
|
407
|
+
* handlers registered later are patched too. The latter is what makes adapters
|
|
408
|
+
* that register handlers post-construction work — e.g. `@rekog/mcp-nest` hands a
|
|
409
|
+
* bare server to instrument() and only then registers its handlers.
|
|
410
|
+
*/
|
|
420
411
|
export function patchRequestHandlers(server: MCPServerLike, patches: Record<string, HandlerPatch>): void {
|
|
421
|
-
// Monkey patch existing handlers.
|
|
422
412
|
for (const [handlerName, patch] of Object.entries(patches)) {
|
|
423
413
|
const originalHandler = server._requestHandlers.get(handlerName)
|
|
424
414
|
if (originalHandler) {
|
|
@@ -519,6 +509,7 @@ export async function handleListToolsRequest(
|
|
|
519
509
|
timestamp: startTime,
|
|
520
510
|
}
|
|
521
511
|
stampMetaClientInfo(event, request)
|
|
512
|
+
stampTransportIdentity(event, extra)
|
|
522
513
|
|
|
523
514
|
if (data) {
|
|
524
515
|
await applyResolvedMetadata(event, data, request, extra)
|
|
@@ -683,7 +674,7 @@ export function cacheToolCategories(cache: Map<string, string>, tools: ListTools
|
|
|
683
674
|
* client name/version is lost after `initialize`. Fix: mint the
|
|
684
675
|
* `Mcp-Session-Id` response header as a token carrying both. Clients replay
|
|
685
676
|
* the header on every request, so any pod recovers them with no server-side
|
|
686
|
-
* store (decoded in `getSessionId`).
|
|
677
|
+
* store (decoded in `getSessionId`). See ADR-0003.
|
|
687
678
|
*
|
|
688
679
|
* The header only reaches the wire when response headers are built after the
|
|
689
680
|
* handler runs — StreamableHTTP with `enableJsonResponse: true`. SSE flushes
|
|
@@ -830,6 +821,7 @@ export async function handleInitializeRequest(
|
|
|
830
821
|
// negotiated protocol version below overrides any `_meta` one); picks up client
|
|
831
822
|
// info if a client also sends it in `_meta`.
|
|
832
823
|
stampMetaClientInfo(event, request)
|
|
824
|
+
stampTransportIdentity(event, extra)
|
|
833
825
|
|
|
834
826
|
await applyResolvedMetadata(event, data, request, extra)
|
|
835
827
|
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
import { MCPAnalyticsEventType } from './event-types'
|
|
16
16
|
import { captureEvent } from './capture'
|
|
17
17
|
import { stampMetaClientInfo } from './client-identity'
|
|
18
|
+
import { stampTransportIdentity } from './transport-identity'
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Bounded LRU cache for session identities, capped at `maxSize` entries so a
|
|
@@ -153,6 +154,7 @@ export async function handleIdentify(
|
|
|
153
154
|
timestamp: new Date(),
|
|
154
155
|
}
|
|
155
156
|
stampMetaClientInfo(identifyEvent, request)
|
|
157
|
+
stampTransportIdentity(identifyEvent, extra)
|
|
156
158
|
|
|
157
159
|
try {
|
|
158
160
|
const identityResult =
|
|
@@ -166,11 +168,10 @@ export async function handleIdentify(
|
|
|
166
168
|
// was already announced by whichever pod handled `initialize`, so only the
|
|
167
169
|
// handshake, or a genuine change a long-lived server observed, publishes
|
|
168
170
|
// $identify. Every event still carries distinct_id/$set regardless, so
|
|
169
|
-
// person properties are never lost. Known gap:
|
|
170
|
-
// `initialize`
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
// announced); revisit with the stateless-by-default rework.
|
|
171
|
+
// person properties are never lost. Known accepted gap (ADR-0003): an
|
|
172
|
+
// identity resolving only after `initialize` on a token session gets no
|
|
173
|
+
// standalone $identify, so its pre-identify events aren't aliased onto
|
|
174
|
+
// the user.
|
|
174
175
|
const changed = previousIdentity !== undefined && !areIdentitiesEqual(previousIdentity, mergedIdentity)
|
|
175
176
|
const firstSeen = previousIdentity === undefined
|
|
176
177
|
const announcedAtInitialize =
|
|
@@ -3,18 +3,16 @@ import { log } from './logger'
|
|
|
3
3
|
import type { AnalyticsInjectableJsonSchema } from './analytics-parameters'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `structuredContent` instead of `content` — which is what clients do whenever a
|
|
9
|
-
* tool declares an `outputSchema`. Measured against Claude Code, correlation for
|
|
10
|
-
* such tools drops to zero.
|
|
11
|
-
*
|
|
12
|
-
* The fix is to mirror the session handle into `structuredContent` as well, which this
|
|
13
|
-
* module does in two halves that must stay in that order:
|
|
6
|
+
* Mirrors the `conversation_id` session handle into `structuredContent`, in two
|
|
7
|
+
* halves that must stay in that order:
|
|
14
8
|
*
|
|
15
9
|
* 1. declare `_mcp_instructions` on the tool's advertised `outputSchema`
|
|
16
10
|
* 2. write it into the result's `structuredContent`
|
|
17
11
|
*
|
|
12
|
+
* Needed because clients that read `structuredContent` — which they do whenever
|
|
13
|
+
* a tool declares an `outputSchema` — never see the `content` text block that
|
|
14
|
+
* carries the handle (ADR-0004).
|
|
15
|
+
*
|
|
18
16
|
* The declaration is what makes the write safe. The MCP client ajv-validates
|
|
19
17
|
* `structuredContent` against the schema from `tools/list`, and
|
|
20
18
|
* `zod-to-json-schema` emits `additionalProperties: false` for a plain
|
|
@@ -151,6 +151,14 @@ function addCommonEventProperties(event: Event, properties: Record<string, unkno
|
|
|
151
151
|
if (event.clientVersion) {
|
|
152
152
|
properties[PostHogMCPAnalyticsProperty.ClientVersion] = event.clientVersion
|
|
153
153
|
}
|
|
154
|
+
// HTTP transports only, and only for the request that carried the header —
|
|
155
|
+
// stdio and in-memory servers simply never set these.
|
|
156
|
+
if (event.clientUserAgent) {
|
|
157
|
+
properties[PostHogMCPAnalyticsProperty.ClientUserAgent] = event.clientUserAgent
|
|
158
|
+
}
|
|
159
|
+
if (event.vendorClient) {
|
|
160
|
+
properties[PostHogMCPAnalyticsProperty.VendorClient] = event.vendorClient
|
|
161
|
+
}
|
|
154
162
|
// Present on every event once negotiated at `initialize` (carried via
|
|
155
163
|
// sessionInfo / the session token). Absent only before the handshake.
|
|
156
164
|
if (event.protocolVersion) {
|
|
@@ -242,6 +250,12 @@ function buildExceptionEvent(event: Event): PostHogCaptureEvent {
|
|
|
242
250
|
if (event.clientVersion) {
|
|
243
251
|
properties[PostHogMCPAnalyticsProperty.ClientVersion] = event.clientVersion
|
|
244
252
|
}
|
|
253
|
+
if (event.clientUserAgent) {
|
|
254
|
+
properties[PostHogMCPAnalyticsProperty.ClientUserAgent] = event.clientUserAgent
|
|
255
|
+
}
|
|
256
|
+
if (event.vendorClient) {
|
|
257
|
+
properties[PostHogMCPAnalyticsProperty.VendorClient] = event.vendorClient
|
|
258
|
+
}
|
|
245
259
|
if (event.protocolVersion) {
|
|
246
260
|
properties[PostHogMCPAnalyticsProperty.ProtocolVersion] = event.protocolVersion
|
|
247
261
|
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from './context-parameters'
|
|
20
20
|
import { MCPAnalyticsEventType } from './event-types'
|
|
21
21
|
import { captureException } from './exceptions'
|
|
22
|
+
import { normalizeHeaderString } from './headers'
|
|
22
23
|
import { applyMcpLibIdentity } from './lib-identity'
|
|
23
24
|
import { log } from './logger'
|
|
24
25
|
import { McpEventSink } from './sink'
|
|
@@ -72,10 +73,6 @@ export interface PostHogMCPOptions extends PostHogOptions {
|
|
|
72
73
|
* ```
|
|
73
74
|
*/
|
|
74
75
|
export class PostHogMCP extends PostHog {
|
|
75
|
-
// Reuses the shared sink so MCP events flow through the identical
|
|
76
|
-
// sanitize/truncate/fan-out pipeline as the `instrument()` path; the sink
|
|
77
|
-
// publishes via `this` (the inherited `capture()`), so the client's own
|
|
78
|
-
// `beforeSend` and batching apply.
|
|
79
76
|
readonly #sink = new McpEventSink(this)
|
|
80
77
|
|
|
81
78
|
// The get_more_tools name lives here (not on the per-call options) so that
|
|
@@ -85,7 +82,6 @@ export class PostHogMCP extends PostHog {
|
|
|
85
82
|
constructor(apiKey: string, options: PostHogMCPOptions = {}) {
|
|
86
83
|
super(apiKey, options)
|
|
87
84
|
this.#missingCapabilityToolName = options.missingCapabilityToolName ?? GET_MORE_TOOLS_NAME
|
|
88
|
-
// Report `$lib: 'posthog-node-mcp'` instead of the inherited `posthog-node`.
|
|
89
85
|
applyMcpLibIdentity(this)
|
|
90
86
|
}
|
|
91
87
|
|
|
@@ -244,6 +240,12 @@ function baseEvent(eventType: MCPAnalyticsEventType, common: McpCaptureCommon):
|
|
|
244
240
|
eventType,
|
|
245
241
|
sessionId: common.sessionId,
|
|
246
242
|
protocolVersion: common.protocolVersion,
|
|
243
|
+
// There is no `extra` on this path, so the host reads the request headers and
|
|
244
|
+
// passes them per capture — the SDK has no transport to read them from. Normalized
|
|
245
|
+
// with the same rules `instrument()` applies, so one request yields one set of
|
|
246
|
+
// properties whichever path captured it.
|
|
247
|
+
clientUserAgent: normalizeHeaderString(common.clientUserAgent),
|
|
248
|
+
vendorClient: normalizeHeaderString(common.vendorClient),
|
|
247
249
|
timestamp: common.timestamp ?? new Date(),
|
|
248
250
|
properties: common.properties,
|
|
249
251
|
groups: common.groups,
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* The token is unsigned: it holds only what the client already self-reports.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { readHeaderValue } from './headers'
|
|
14
|
+
|
|
13
15
|
export const MCP_SESSION_HEADER = 'mcp-session-id'
|
|
14
16
|
|
|
15
17
|
/** What a session token carries. */
|
|
@@ -105,21 +107,7 @@ export function decodeSessionId(value: unknown): SessionTokenPayload | null {
|
|
|
105
107
|
* transports lowercase header keys; the fallback scan covers hand-built extras.
|
|
106
108
|
*/
|
|
107
109
|
export function readMcpSessionHeader(headers: unknown): string | undefined {
|
|
108
|
-
|
|
109
|
-
return undefined
|
|
110
|
-
}
|
|
111
|
-
const record = headers as Record<string, unknown>
|
|
112
|
-
let value = record[MCP_SESSION_HEADER]
|
|
113
|
-
if (value === undefined) {
|
|
114
|
-
const key = Object.keys(record).find((k) => k.toLowerCase() === MCP_SESSION_HEADER)
|
|
115
|
-
value = key === undefined ? undefined : record[key]
|
|
116
|
-
}
|
|
117
|
-
const first = Array.isArray(value) ? value[0] : value
|
|
118
|
-
if (typeof first !== 'string') {
|
|
119
|
-
return undefined
|
|
120
|
-
}
|
|
121
|
-
const trimmed = first.trim()
|
|
122
|
-
return trimmed.length > 0 ? trimmed : undefined
|
|
110
|
+
return readHeaderValue(headers, MCP_SESSION_HEADER)
|
|
123
111
|
}
|
|
124
112
|
|
|
125
113
|
/**
|
|
@@ -30,9 +30,10 @@ export function deriveSessionIdFromMCPSession(mcpSessionId: string): string {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Derives the SDK session id from the agent's conversation handle.
|
|
34
|
-
* and unsalted on purpose: two pods that never met must agree on
|
|
35
|
-
* the 2026-07-28 revision leaves them no shared state to agree
|
|
33
|
+
* Derives the SDK session id from the agent's conversation handle (ADR-0004).
|
|
34
|
+
* Deterministic and unsalted on purpose: two pods that never met must agree on
|
|
35
|
+
* the session, and the 2026-07-28 revision leaves them no shared state to agree
|
|
36
|
+
* through.
|
|
36
37
|
*
|
|
37
38
|
* Hashed rather than used verbatim so an MCP session can never collide with a
|
|
38
39
|
* Session Replay id — a bare uuidv7 would render a "View recording" button that
|
|
@@ -69,26 +70,16 @@ export function getSessionId(
|
|
|
69
70
|
throw new Error('Server tracking data not found')
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
// 1. The agent's conversation handle
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// This returns instead of falling through to the shared-state writes at the
|
|
78
|
-
// end: the handle belongs to this one request, and persisting it would leak
|
|
79
|
-
// one chat's session onto a concurrent chat's `tools/list`.
|
|
80
|
-
//
|
|
81
|
-
// `lastActivity` therefore does not advance either, so a conversation that
|
|
82
|
-
// always echoes lets the in-memory fallback age past the inactivity timeout —
|
|
83
|
-
// and a later call carrying no handle rotates it. That is the honest reading:
|
|
84
|
-
// the fallback session really has been idle for that whole time.
|
|
73
|
+
// 1. The agent's conversation handle — the only id that survives reconnects,
|
|
74
|
+
// restarts, and the per-request server instances of the 2026-07-28 revision
|
|
75
|
+
// (ADR-0004). Returns before the shared-state writes at the end: the handle
|
|
76
|
+
// belongs to this one request, and persisting it (or advancing lastActivity)
|
|
77
|
+
// would leak one chat's session onto a concurrent chat's `tools/list`.
|
|
85
78
|
if (conversationId) {
|
|
86
|
-
// The
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// request told us about the client. Without this, tool calls and `$identify`
|
|
91
|
-
// go out unattributed on exactly the deployments this branch exists for.
|
|
79
|
+
// The session comes from the handle, but on an instance that never processed
|
|
80
|
+
// `initialize` the request's token is still the only source of client
|
|
81
|
+
// identity — without this, tool calls and `$identify` go out unattributed
|
|
82
|
+
// on exactly the deployments this branch exists for.
|
|
92
83
|
applyTokenClientIdentity(data, extra)
|
|
93
84
|
return deriveSessionIdFromConversation(conversationId)
|
|
94
85
|
}
|
|
@@ -110,14 +101,10 @@ export function getSessionId(
|
|
|
110
101
|
/**
|
|
111
102
|
* Restores the client name/version and protocol version baked into our token at
|
|
112
103
|
* mint time, and hands the decoded token back so the caller can also take the
|
|
113
|
-
* session id from it.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* request must not adopt it. The client identity is per-connection — the same
|
|
118
|
-
* client for every request on it — so a conversation-anchored request should,
|
|
119
|
-
* and on a stateless instance that never saw `initialize` this is the only
|
|
120
|
-
* source of it.
|
|
104
|
+
* session id from it. Split from step 2 because the token's two halves have
|
|
105
|
+
* different scopes: its session id is per-chat (a conversation-anchored request
|
|
106
|
+
* must not adopt it), its client identity is per-connection (every request on
|
|
107
|
+
* the connection should).
|
|
121
108
|
*/
|
|
122
109
|
function applyTokenClientIdentity(
|
|
123
110
|
data: MCPAnalyticsData,
|
|
@@ -139,9 +126,8 @@ function applyTokenClientIdentity(
|
|
|
139
126
|
* issued. Returns undefined when the request carried neither, which is what
|
|
140
127
|
* sends the caller on to 3.
|
|
141
128
|
*
|
|
142
|
-
* Both are 2025-11-25 mechanisms
|
|
143
|
-
* outright
|
|
144
|
-
* legacy-only once era detection lands.
|
|
129
|
+
* Both are 2025-11-25 mechanisms; the 2026-07-28 revision removed the header
|
|
130
|
+
* outright, so this step is legacy-only once era detection lands (ADR-0003).
|
|
145
131
|
*/
|
|
146
132
|
function readSessionIdFromRequest(data: MCPAnalyticsData, extra?: CompatibleRequestHandlerExtra): string | undefined {
|
|
147
133
|
// 2a. A token we minted at `initialize` and the client replayed. It rides the
|
|
@@ -203,8 +189,8 @@ export function getSessionInfo(
|
|
|
203
189
|
const actorInfo = data?.identifiedSessions.get(sessionId ?? data.sessionId)
|
|
204
190
|
|
|
205
191
|
const sessionInfo: SessionInfo = {
|
|
206
|
-
ipAddress: undefined,
|
|
207
|
-
sdkLanguage: 'TypeScript',
|
|
192
|
+
ipAddress: undefined,
|
|
193
|
+
sdkLanguage: 'TypeScript',
|
|
208
194
|
sdkVersion: version,
|
|
209
195
|
serverName: server._serverInfo?.name,
|
|
210
196
|
serverVersion: server._serverInfo?.version,
|
package/src/extensions/sink.ts
CHANGED
|
@@ -92,9 +92,8 @@ async function applyBeforeSend(
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* Wraps a user-supplied `posthog-node` client
|
|
96
|
-
*
|
|
97
|
-
* events, applies `beforeSend`, and hands each to `posthog.capture()`.
|
|
95
|
+
* Wraps a user-supplied `posthog-node` client: runs every MCP event through
|
|
96
|
+
* {@link processMcpEvent} and hands each surviving payload to `posthog.capture()`.
|
|
98
97
|
*
|
|
99
98
|
* The SDK does not own the client lifecycle — the host application constructs
|
|
100
99
|
* the `PostHog` instance and is responsible for `shutdown()` (matching
|
|
@@ -107,9 +106,8 @@ export class McpEventSink {
|
|
|
107
106
|
) {}
|
|
108
107
|
|
|
109
108
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* dropped, never re-thrown into tool code.
|
|
109
|
+
* Errors at any stage are logged and the event is dropped, never re-thrown
|
|
110
|
+
* into tool code.
|
|
113
111
|
*/
|
|
114
112
|
async capture(event: McpEvent, options: McpCaptureOptions): Promise<void> {
|
|
115
113
|
const result = await processMcpEvent(event, options, this.logger)
|
package/src/extensions/tools.ts
CHANGED
|
@@ -36,15 +36,12 @@ export function getReportMissingToolDescriptor(name: string = GET_MORE_TOOLS_NAM
|
|
|
36
36
|
},
|
|
37
37
|
annotations: {
|
|
38
38
|
title: 'Get More Tools',
|
|
39
|
-
// Doesn't mutate state on the MCP server
|
|
40
39
|
readOnlyHint: true,
|
|
41
|
-
// Interacts with external
|
|
40
|
+
// Interacts with an external entity: the report lands in analytics.
|
|
42
41
|
openWorldHint: true,
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// It's also preferable to track it as idempontent to make agents more prone to call it proactively.
|
|
42
|
+
// Only records the gap, so repeat calls are harmless — and advertising it
|
|
43
|
+
// as idempotent makes agents more willing to call it proactively.
|
|
46
44
|
idempotentHint: true,
|
|
47
|
-
// Never deletes any data from the MCP server
|
|
48
45
|
destructiveHint: false,
|
|
49
46
|
},
|
|
50
47
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { CompatibleRequestHandlerExtra, McpEvent } from '../types'
|
|
2
|
+
import { readHeaderValue } from './headers'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transport-level client identity: the two request headers that say *which
|
|
6
|
+
* product* is calling, where `clientInfo` only says which client library is.
|
|
7
|
+
*
|
|
8
|
+
* MCP's own identity fields are too coarse to attribute usage to a surface. A
|
|
9
|
+
* vendor ships many products on one client: Anthropic reports
|
|
10
|
+
* `clientInfo.name = "claude-code"` from the CLI, the Agent SDK, the VS Code
|
|
11
|
+
* extension, the desktop app and more, so `$mcp_client_name` collapses all of
|
|
12
|
+
* them into one bucket. The distinguishing detail exists only in the User-Agent
|
|
13
|
+
* parenthetical — `claude-code/2.1.0 (cli)` vs `claude-code/2.1.0 (sdk-ts)` vs
|
|
14
|
+
* `claude-code/2.1.0 (claude-vscode)` — alongside vendor-specific headers like
|
|
15
|
+
* `x-anthropic-client`. Capturing them is the only way a server owner can tell
|
|
16
|
+
* their surfaces apart.
|
|
17
|
+
*
|
|
18
|
+
* We capture the raw strings and classify nothing. No vendor table, no product
|
|
19
|
+
* labels: friendly names are resolved at query time server-side, so labels can
|
|
20
|
+
* improve (and new surfaces can appear) without waiting on an SDK release, and
|
|
21
|
+
* there is exactly one resolver rather than one per installed SDK version.
|
|
22
|
+
*
|
|
23
|
+
* This is deliberately separate from `client-identity.ts`. That module reads the
|
|
24
|
+
* request body's `_meta` and therefore works on every transport, including stdio
|
|
25
|
+
* and in-memory. Headers exist only on HTTP transports, so everything here is a
|
|
26
|
+
* silent no-op elsewhere and those events stay byte-identical to before.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Header carrying the client's product/surface, e.g. `claude-code/2.1.0 (cli)`. */
|
|
30
|
+
export const CLIENT_USER_AGENT_HEADER = 'user-agent'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Vendor-specific client header. Anthropic's clients send it alongside the
|
|
34
|
+
* User-Agent; it is captured verbatim as a second, independent signal rather
|
|
35
|
+
* than merged into one, so a query-time resolver can prefer whichever the
|
|
36
|
+
* vendor keeps stable.
|
|
37
|
+
*/
|
|
38
|
+
export const VENDOR_CLIENT_HEADER = 'x-anthropic-client'
|
|
39
|
+
|
|
40
|
+
export interface TransportIdentity {
|
|
41
|
+
clientUserAgent?: string
|
|
42
|
+
vendorClient?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reads the transport identity headers off `extra.requestInfo.headers`. Returns
|
|
47
|
+
* `undefined` when the request carries neither — including every stdio and
|
|
48
|
+
* in-memory request, which has no `requestInfo` at all. Never throws.
|
|
49
|
+
*/
|
|
50
|
+
export function readTransportIdentity(extra: CompatibleRequestHandlerExtra | undefined): TransportIdentity | undefined {
|
|
51
|
+
const headers = extra?.requestInfo?.headers
|
|
52
|
+
if (!headers) {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
const result: TransportIdentity = {}
|
|
56
|
+
|
|
57
|
+
const clientUserAgent = readHeaderValue(headers, CLIENT_USER_AGENT_HEADER)
|
|
58
|
+
if (clientUserAgent) {
|
|
59
|
+
result.clientUserAgent = clientUserAgent
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const vendorClient = readHeaderValue(headers, VENDOR_CLIENT_HEADER)
|
|
63
|
+
if (vendorClient) {
|
|
64
|
+
result.vendorClient = vendorClient
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result.clientUserAgent || result.vendorClient ? result : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stamps the transport identity onto the event being built for *this* request,
|
|
72
|
+
* so it carries `$mcp_client_user_agent` and `$mcp_vendor_client`.
|
|
73
|
+
*
|
|
74
|
+
* Headers are per-request, so this writes to the event — a per-request object —
|
|
75
|
+
* and never to the server-wide `sessionInfo`. One instrumented server can
|
|
76
|
+
* multiplex concurrent requests from different clients, and caching a header
|
|
77
|
+
* into shared state would attribute one client's surface to another's event.
|
|
78
|
+
* `client-identity.ts` and `capture.ts` guard the same hazard.
|
|
79
|
+
*
|
|
80
|
+
* Values are capped downstream by `truncateEvent`, which runs on every capture
|
|
81
|
+
* path, so a hostile 1MB header cannot inflate an event.
|
|
82
|
+
*/
|
|
83
|
+
export function stampTransportIdentity(event: McpEvent, extra: CompatibleRequestHandlerExtra | undefined): void {
|
|
84
|
+
const identity = readTransportIdentity(extra)
|
|
85
|
+
if (!identity) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (identity.clientUserAgent) {
|
|
89
|
+
event.clientUserAgent = identity.clientUserAgent
|
|
90
|
+
}
|
|
91
|
+
if (identity.vendorClient) {
|
|
92
|
+
event.vendorClient = identity.vendorClient
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -430,6 +430,10 @@ export function truncateEvent<T extends Event | McpEvent>(event: T): T {
|
|
|
430
430
|
result.serverVersion = truncateString(result.serverVersion, MAX_METADATA_LENGTH)
|
|
431
431
|
result.clientName = truncateString(result.clientName, MAX_METADATA_LENGTH)
|
|
432
432
|
result.clientVersion = truncateString(result.clientVersion, MAX_METADATA_LENGTH)
|
|
433
|
+
// Client-supplied HTTP headers, so unlike the fields above they are attacker-
|
|
434
|
+
// controlled in length. Any real User-Agent fits well inside the metadata cap.
|
|
435
|
+
result.clientUserAgent = truncateString(result.clientUserAgent, MAX_METADATA_LENGTH)
|
|
436
|
+
result.vendorClient = truncateString(result.vendorClient, MAX_METADATA_LENGTH)
|
|
433
437
|
result.errorType = truncateString(result.errorType, MAX_METADATA_LENGTH)
|
|
434
438
|
|
|
435
439
|
// Error field limits — operate on the core `$exception_list` shape
|
package/src/types.ts
CHANGED
|
@@ -151,6 +151,15 @@ export type BeforeSendFn = (event: PostHogCaptureEvent) => MaybePromise<PostHogC
|
|
|
151
151
|
export interface Event {
|
|
152
152
|
actorId?: string
|
|
153
153
|
clientName?: string
|
|
154
|
+
/**
|
|
155
|
+
* Raw `user-agent` request header → `$mcp_client_user_agent`. HTTP transports
|
|
156
|
+
* only. The only place a client's *surface* is distinguishable: one vendor
|
|
157
|
+
* ships many products under a single `clientName` (Anthropic's CLI, Agent SDK
|
|
158
|
+
* and VS Code extension all report `claude-code`), and only the User-Agent
|
|
159
|
+
* parenthetical tells them apart. Captured verbatim — surfaces are resolved to
|
|
160
|
+
* friendly labels at query time, never in this SDK.
|
|
161
|
+
*/
|
|
162
|
+
clientUserAgent?: string
|
|
154
163
|
clientVersion?: string
|
|
155
164
|
conversationId?: string
|
|
156
165
|
duration?: number
|
|
@@ -199,6 +208,12 @@ export interface Event {
|
|
|
199
208
|
toolDescription?: string
|
|
200
209
|
userIntent?: string
|
|
201
210
|
userIntentSource?: MCPAnalyticsIntentSource
|
|
211
|
+
/**
|
|
212
|
+
* Raw vendor client header (`x-anthropic-client`) → `$mcp_vendor_client`. HTTP
|
|
213
|
+
* transports only. A second, independent surface signal alongside
|
|
214
|
+
* {@link Event.clientUserAgent}; captured verbatim, never classified.
|
|
215
|
+
*/
|
|
216
|
+
vendorClient?: string
|
|
202
217
|
}
|
|
203
218
|
|
|
204
219
|
/** A partially-built MCP event as it flows through the SDK before capture. */
|
|
@@ -344,6 +359,24 @@ export interface McpCaptureCommon {
|
|
|
344
359
|
* not just the initialize event — the `PostHogMCP` client holds no per-session state.
|
|
345
360
|
*/
|
|
346
361
|
protocolVersion?: string
|
|
362
|
+
/**
|
|
363
|
+
* Raw `user-agent` request header → `$mcp_client_user_agent`. The
|
|
364
|
+
* `instrument()` path reads this off the transport automatically; a custom
|
|
365
|
+
* dispatcher has no `extra`, so pass `req.headers['user-agent']` yourself.
|
|
366
|
+
*
|
|
367
|
+
* Worth wiring up: it is the only signal that separates a vendor's surfaces
|
|
368
|
+
* (Anthropic's CLI, Agent SDK and VS Code extension all report
|
|
369
|
+
* `clientName: "claude-code"`, and only the User-Agent parenthetical —
|
|
370
|
+
* `(cli)` / `(sdk-ts)` / `(claude-vscode)` — tells them apart). Send it raw;
|
|
371
|
+
* PostHog resolves friendly product labels at query time.
|
|
372
|
+
*/
|
|
373
|
+
clientUserAgent?: string
|
|
374
|
+
/**
|
|
375
|
+
* Raw vendor client header → `$mcp_vendor_client`. Anthropic's clients send
|
|
376
|
+
* `x-anthropic-client`; pass it as a second, independent surface signal
|
|
377
|
+
* alongside {@link McpCaptureCommon.clientUserAgent}. Sent verbatim.
|
|
378
|
+
*/
|
|
379
|
+
vendorClient?: string
|
|
347
380
|
/** Person properties → `$set` (e.g. `{ name, email, plan }`). */
|
|
348
381
|
setProperties?: JsonRecord
|
|
349
382
|
/** Group memberships → `$groups`. */
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '0.
|
|
1
|
+
export const version = '0.11.0'
|