@posthog/mcp 0.8.0 → 0.9.1

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.
Files changed (74) hide show
  1. package/LICENSE +2 -2
  2. package/README.md +59 -0
  3. package/dist/extensions/capture.d.ts.map +1 -1
  4. package/dist/extensions/compatibility.d.ts.map +1 -1
  5. package/dist/extensions/constants.d.ts.map +1 -1
  6. package/dist/extensions/context-parameters.d.ts.map +1 -1
  7. package/dist/extensions/conversation-id.d.ts.map +1 -1
  8. package/dist/extensions/event-types.d.ts.map +1 -1
  9. package/dist/extensions/exceptions.d.ts.map +1 -1
  10. package/dist/extensions/ids.d.ts.map +1 -1
  11. package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
  12. package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
  13. package/dist/extensions/instrumentation.d.ts.map +1 -1
  14. package/dist/extensions/instrumentation.js +39 -3
  15. package/dist/extensions/instrumentation.mjs +41 -5
  16. package/dist/extensions/intent.d.ts.map +1 -1
  17. package/dist/extensions/internal.d.ts +4 -2
  18. package/dist/extensions/internal.d.ts.map +1 -1
  19. package/dist/extensions/internal.js +5 -2
  20. package/dist/extensions/internal.mjs +5 -2
  21. package/dist/extensions/logger.d.ts.map +1 -1
  22. package/dist/extensions/mcp-payloads.d.ts.map +1 -1
  23. package/dist/extensions/mcp-sdk-compat.d.ts.map +1 -1
  24. package/dist/extensions/posthog-events.d.ts.map +1 -1
  25. package/dist/extensions/sanitization.d.ts.map +1 -1
  26. package/dist/extensions/session-token.d.ts +46 -0
  27. package/dist/extensions/session-token.d.ts.map +1 -0
  28. package/dist/extensions/session-token.js +122 -0
  29. package/dist/extensions/session-token.mjs +76 -0
  30. package/dist/extensions/session.d.ts +5 -4
  31. package/dist/extensions/session.d.ts.map +1 -1
  32. package/dist/extensions/session.js +25 -33
  33. package/dist/extensions/session.mjs +22 -27
  34. package/dist/extensions/sink.d.ts.map +1 -1
  35. package/dist/extensions/tools.d.ts.map +1 -1
  36. package/dist/extensions/tracing-helpers.d.ts.map +1 -1
  37. package/dist/extensions/truncation.d.ts.map +1 -1
  38. package/dist/index.d.ts +3 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +15 -2
  41. package/dist/index.mjs +2 -1
  42. package/dist/types.d.ts +18 -2
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/version.mjs +1 -1
  47. package/package.json +3 -3
  48. package/src/extensions/capture.ts +4 -3
  49. package/src/extensions/compatibility.ts +4 -3
  50. package/src/extensions/constants.ts +4 -3
  51. package/src/extensions/context-parameters.ts +4 -3
  52. package/src/extensions/conversation-id.ts +4 -3
  53. package/src/extensions/event-types.ts +4 -3
  54. package/src/extensions/exceptions.ts +4 -3
  55. package/src/extensions/ids.ts +4 -3
  56. package/src/extensions/instrument-highlevel.ts +4 -3
  57. package/src/extensions/instrument-lowlevel.ts +4 -3
  58. package/src/extensions/instrumentation.ts +85 -9
  59. package/src/extensions/intent.ts +4 -3
  60. package/src/extensions/internal.ts +23 -7
  61. package/src/extensions/logger.ts +4 -3
  62. package/src/extensions/mcp-payloads.ts +4 -3
  63. package/src/extensions/mcp-sdk-compat.ts +4 -3
  64. package/src/extensions/posthog-events.ts +4 -3
  65. package/src/extensions/sanitization.ts +4 -3
  66. package/src/extensions/session-token.ts +168 -0
  67. package/src/extensions/session.ts +42 -40
  68. package/src/extensions/sink.ts +4 -3
  69. package/src/extensions/tools.ts +4 -3
  70. package/src/extensions/tracing-helpers.ts +4 -3
  71. package/src/extensions/truncation.ts +4 -3
  72. package/src/index.ts +14 -4
  73. package/src/types.ts +24 -5
  74. package/src/version.ts +1 -1
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import { ErrorTracking as CoreErrorTracking } from '@posthog/core'
6
7
 
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import { uuidv7 } from '@posthog/core'
6
7
 
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
6
7
  import type { CompatibleRequestHandlerExtra, HighLevelMCPServerLike, MCPServerLike, RegisteredTool } from '../types'
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
6
7
  import type { CompatibleRequestHandlerExtra, MCPRequestLike, MCPServerLike } from '../types'
@@ -1,9 +1,17 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import type { ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
6
- import type { CompatibleRequestHandlerExtra, MCPAnalyticsData, MCPRequestLike, MCPServerLike, McpEvent } from '../types'
7
+ import type {
8
+ CompatibleRequestHandlerExtra,
9
+ MCPAnalyticsData,
10
+ MCPRequestLike,
11
+ MCPServerLike,
12
+ McpEvent,
13
+ ServerClientInfoLike,
14
+ } from '../types'
7
15
  import { addContextParameterToTools, getContextDescription, isContextEnabled } from './context-parameters'
8
16
  import {
9
17
  addConversationIdToTools,
@@ -17,11 +25,12 @@ import { captureEvent } from './capture'
17
25
  import { MCPAnalyticsEventType } from './event-types'
18
26
  import { captureException } from './exceptions'
19
27
  import { resolveToolCallIntent, setEventIntent, setExplicitContextIntent } from './intent'
20
- import { getServerTrackingData, handleIdentify } from './internal'
28
+ import { getServerTrackingData, handleIdentify, setServerTrackingData } from './internal'
21
29
  import { log } from './logger'
22
30
  import { buildCapturedMcpParameters } from './mcp-payloads'
23
31
  import { getLiteralValue, getObjectShape } from './mcp-sdk-compat'
24
- import { getServerSessionId } from './session'
32
+ import { getSessionId, newSessionId } from './session'
33
+ import { encodeSessionId, readMcpSessionHeader, writeSessionIdToTransport } from './session-token'
25
34
  import { getReportMissingToolDescriptor, resolveMissingCapabilityToolName } from './tools'
26
35
  import { applyResolvedMetadata, isToolResultError } from './tracing-helpers'
27
36
 
@@ -129,7 +138,7 @@ async function prepareToolCallEvent(
129
138
  eventType: MCPAnalyticsEventType
130
139
  ): Promise<McpEvent | null> {
131
140
  try {
132
- const sessionId = getServerSessionId(server, extra)
141
+ const sessionId = getSessionId(server, extra)
133
142
  await handleIdentify(server, data, sessionId, request, extra)
134
143
 
135
144
  const toolName = request.params?.name
@@ -283,7 +292,7 @@ export async function handleListToolsRequest(
283
292
  const data = getServerTrackingData(server)
284
293
  const startTime = new Date()
285
294
  const event: McpEvent = {
286
- sessionId: getServerSessionId(server, extra),
295
+ sessionId: getSessionId(server, extra),
287
296
  parameters: buildCapturedMcpParameters(request),
288
297
  eventType: MCPAnalyticsEventType.mcpToolsList,
289
298
  timestamp: startTime,
@@ -410,6 +419,71 @@ export function cacheToolCategories(cache: Map<string, string>, tools: ListTools
410
419
 
411
420
  // --- initialize -----------------------------------------------------------
412
421
 
422
+ /**
423
+ * Stateless servers never issue a session id, so sessions fragment and the
424
+ * client name/version is lost after `initialize`. Fix: mint the
425
+ * `Mcp-Session-Id` response header as a token carrying both. Clients replay
426
+ * the header on every request, so any pod recovers them with no server-side
427
+ * store (decoded in `getSessionId`).
428
+ *
429
+ * The header only reaches the wire when response headers are built after the
430
+ * handler runs — StreamableHTTP with `enableJsonResponse: true`. SSE flushes
431
+ * headers first; those servers set the header themselves with the exported
432
+ * `encodeSessionId`, and this mint is a harmless no-op.
433
+ */
434
+ function mintStatelessSessionOnInitialize(
435
+ server: MCPServerLike,
436
+ data: MCPAnalyticsData,
437
+ request: MCPRequestLike,
438
+ extra: CompatibleRequestHandlerExtra | undefined
439
+ ): void {
440
+ try {
441
+ const headers = extra?.requestInfo?.headers
442
+ if (!headers || typeof headers !== 'object') {
443
+ return // not an HTTP transport (stdio/in-memory) — nothing to mint into
444
+ }
445
+ if (readMcpSessionHeader(headers)) {
446
+ return // client already replays a session id (ours or the transport's)
447
+ }
448
+ const transport = server.transport
449
+ if (!transport || extra?.sessionId || transport.sessionId) {
450
+ return // stateful transports manage their own session id — leave it alone
451
+ }
452
+
453
+ const sessionId = newSessionId()
454
+ const clientInfo = readInitializeClientInfo(request)
455
+ const token = encodeSessionId({ sessionId, clientName: clientInfo?.name, clientVersion: clientInfo?.version })
456
+ if (!writeSessionIdToTransport(transport, token)) {
457
+ return // transport can't carry a response session id — keep generated behavior
458
+ }
459
+
460
+ data.sessionId = sessionId
461
+ data.sessionSource = 'token'
462
+ data.sessionInfo.clientName = clientInfo?.name
463
+ data.sessionInfo.clientVersion = clientInfo?.version
464
+ data.lastActivity = new Date()
465
+ setServerTrackingData(server, data)
466
+ } catch (error) {
467
+ log(`Warning: PostHog MCP analytics failed to mint a stateless session id - ${error}`)
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Read the client name/version off the `initialize` request body — the SDK
473
+ * hasn't stored it yet (`getClientVersion()`) when our patch runs.
474
+ */
475
+ function readInitializeClientInfo(request: MCPRequestLike): ServerClientInfoLike | undefined {
476
+ const clientInfo = request.params?.clientInfo
477
+ if (!clientInfo || typeof clientInfo !== 'object') {
478
+ return undefined
479
+ }
480
+ const { name, version } = clientInfo as Record<string, unknown>
481
+ return {
482
+ name: typeof name === 'string' ? name : undefined,
483
+ version: typeof version === 'string' ? version : undefined,
484
+ }
485
+ }
486
+
413
487
  /**
414
488
  * Captures the connection handshake (and resolves identity) on `initialize`
415
489
  * before the original handler runs.
@@ -428,7 +502,9 @@ export async function handleInitializeRequest(
428
502
  return await originalInitializeHandler(request, extra)
429
503
  }
430
504
 
431
- const sessionId = getServerSessionId(server, extra)
505
+ // Mint first so the `$mcp_initialize` event below already carries the minted id.
506
+ mintStatelessSessionOnInitialize(server, data, request, extra)
507
+ const sessionId = getSessionId(server, extra)
432
508
  await handleIdentify(server, data, sessionId, request, extra)
433
509
 
434
510
  const event: McpEvent = {
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import type {
6
7
  CompatibleRequestHandlerExtra,
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import type {
6
7
  CompatibleRequestHandlerExtra,
@@ -117,8 +118,10 @@ export function mergeIdentities(previous: UserIdentity | undefined, next: UserId
117
118
  }
118
119
 
119
120
  /**
120
- * Resolves the optional `identify` callback, dedupes against the server's identity
121
- * cache, and publishes an `$identify` event only when the identity has materially changed.
121
+ * Resolves the optional `identify` callback on every request (the resolved
122
+ * identity is what stamps `distinct_id`/`$set` onto events), but publishes the
123
+ * `$identify` event at most once per session: at `initialize`, when a
124
+ * long-lived server first sees the identity, or when it materially changes.
122
125
  */
123
126
  export async function handleIdentify(
124
127
  server: MCPServerLike,
@@ -146,11 +149,24 @@ export async function handleIdentify(
146
149
  if (identityResult) {
147
150
  const previousIdentity = data.identifiedSessions.get(sessionId)
148
151
  const mergedIdentity = mergeIdentities(previousIdentity, identityResult)
149
- const hasChanged = !(previousIdentity && areIdentitiesEqual(previousIdentity, mergedIdentity))
152
+ // "First seen" means new to this instance's cache — which on a stateless
153
+ // pod (fresh instrument() per request) is every request. A token session
154
+ // was already announced by whichever pod handled `initialize`, so only the
155
+ // handshake, or a genuine change a long-lived server observed, publishes
156
+ // $identify. Every event still carries distinct_id/$set regardless, so
157
+ // person properties are never lost. Known gap: if identity is null at
158
+ // `initialize` but resolves later on a token session, that first $identify
159
+ // is suppressed too, so any pre-identify (anonymous) events aren't aliased
160
+ // onto the user. Inherent to statelessness (no pod knows a sibling already
161
+ // announced); revisit with the stateless-by-default rework.
162
+ const changed = previousIdentity !== undefined && !areIdentitiesEqual(previousIdentity, mergedIdentity)
163
+ const firstSeen = previousIdentity === undefined
164
+ const announcedAtInitialize = data.sessionSource === 'token' && request.method !== 'initialize'
165
+ const shouldPublish = changed || (firstSeen && !announcedAtInitialize)
150
166
 
151
167
  data.identifiedSessions.set(sessionId, mergedIdentity)
152
168
 
153
- if (hasChanged) {
169
+ if (shouldPublish) {
154
170
  log(`Identified session ${sessionId} with identity: ${JSON.stringify(mergedIdentity)}`)
155
171
  captureEvent(server, identifyEvent)
156
172
  }
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  /**
6
7
  * STDIO-safe logger.
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  const CONTEXT_ARGUMENT_NAME = 'context'
6
7
  const REDACTED_VALUE = '[redacted]'
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  /**
6
7
  * MCP SDK Compatibility Helpers
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import type { Event } from '../types'
6
7
  import { POSTHOG_MCP_ANALYTICS_SOURCE, PostHogMCPAnalyticsEvent, PostHogMCPAnalyticsProperty } from './constants'
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import type { Event, McpEvent } from '../types'
6
7
  import { sanitizeCapturedValue } from './mcp-payloads'
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Self-encoded session tokens for stateless / multi-pod MCP servers.
3
+ *
4
+ * A stateless server keeps nothing between requests, so every request starts a
5
+ * new session and the client name/version (only sent at `initialize`) is lost.
6
+ * The one value clients replay on every request is the `Mcp-Session-Id` header.
7
+ * So at `initialize` we mint that header as a token carrying the session id and
8
+ * client identity — any pod can read them back from the header alone.
9
+ *
10
+ * The token is unsigned: it holds only what the client already self-reports.
11
+ */
12
+
13
+ export const MCP_SESSION_HEADER = 'mcp-session-id'
14
+
15
+ /** What a session token carries. */
16
+ export interface SessionTokenPayload {
17
+ /** PostHog session id (`ses_…`) → `$session_id`. */
18
+ sessionId: string
19
+ /** MCP client name → `$mcp_client_name`. */
20
+ clientName?: string
21
+ /** MCP client version → `$mcp_client_version`. */
22
+ clientVersion?: string
23
+ }
24
+
25
+ // On the wire the token is base64url(JSON) with shortened keys to keep the
26
+ // header small: sid = sessionId, cn = clientName, cv = clientVersion.
27
+ const MAX_TOKEN_LENGTH = 4096
28
+ const MAX_SESSION_ID_LENGTH = 128
29
+ const MAX_CLIENT_FIELD_LENGTH = 200
30
+
31
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/
32
+
33
+ /**
34
+ * Encodes a session token for the `Mcp-Session-Id` response header. Also
35
+ * exported for SSE servers, which flush headers before handlers run and so
36
+ * must set the header themselves at the HTTP layer.
37
+ */
38
+ export function encodeSessionId(payload: SessionTokenPayload): string {
39
+ if (!payload || typeof payload.sessionId !== 'string' || payload.sessionId.length === 0) {
40
+ throw new Error('encodeSessionId requires a non-empty `sessionId` (use newSessionId())')
41
+ }
42
+ const wire: Record<string, string> = { sid: payload.sessionId }
43
+ if (typeof payload.clientName === 'string' && payload.clientName.length > 0) {
44
+ wire.cn = payload.clientName.slice(0, MAX_CLIENT_FIELD_LENGTH)
45
+ }
46
+ if (typeof payload.clientVersion === 'string' && payload.clientVersion.length > 0) {
47
+ wire.cv = payload.clientVersion.slice(0, MAX_CLIENT_FIELD_LENGTH)
48
+ }
49
+ return utf8ToBase64Url(JSON.stringify(wire))
50
+ }
51
+
52
+ /**
53
+ * Decodes an `Mcp-Session-Id` value into a token payload. Returns `null` for
54
+ * anything that isn't one of our tokens (transport UUIDs, JWTs, garbage) and
55
+ * never throws.
56
+ */
57
+ export function decodeSessionId(value: unknown): SessionTokenPayload | null {
58
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_TOKEN_LENGTH) {
59
+ return null
60
+ }
61
+ // JWTs carry dots; UUIDs pass this check but fail JSON.parse below.
62
+ if (!BASE64URL_PATTERN.test(value)) {
63
+ return null
64
+ }
65
+ let parsed: unknown
66
+ try {
67
+ parsed = JSON.parse(base64UrlToUtf8(value))
68
+ } catch {
69
+ return null
70
+ }
71
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
72
+ return null
73
+ }
74
+ const wire = parsed as Record<string, unknown>
75
+ if (typeof wire.sid !== 'string' || wire.sid.length === 0 || wire.sid.length > MAX_SESSION_ID_LENGTH) {
76
+ return null
77
+ }
78
+ const payload: SessionTokenPayload = { sessionId: wire.sid }
79
+ // A bad cn/cv just means no client info — it does not reject the token.
80
+ if (typeof wire.cn === 'string' && wire.cn.length > 0) {
81
+ payload.clientName = wire.cn.slice(0, MAX_CLIENT_FIELD_LENGTH)
82
+ }
83
+ if (typeof wire.cv === 'string' && wire.cv.length > 0) {
84
+ payload.clientVersion = wire.cv.slice(0, MAX_CLIENT_FIELD_LENGTH)
85
+ }
86
+ return payload
87
+ }
88
+
89
+ /**
90
+ * Reads the `mcp-session-id` header off `extra.requestInfo.headers`. The SDK
91
+ * transports lowercase header keys; the fallback scan covers hand-built extras.
92
+ */
93
+ export function readMcpSessionHeader(headers: unknown): string | undefined {
94
+ if (!headers || typeof headers !== 'object') {
95
+ return undefined
96
+ }
97
+ const record = headers as Record<string, unknown>
98
+ let value = record[MCP_SESSION_HEADER]
99
+ if (value === undefined) {
100
+ const key = Object.keys(record).find((k) => k.toLowerCase() === MCP_SESSION_HEADER)
101
+ value = key === undefined ? undefined : record[key]
102
+ }
103
+ const first = Array.isArray(value) ? value[0] : value
104
+ if (typeof first !== 'string') {
105
+ return undefined
106
+ }
107
+ const trimmed = first.trim()
108
+ return trimmed.length > 0 ? trimmed : undefined
109
+ }
110
+
111
+ /**
112
+ * Puts a minted token on the transport so it goes out as the `Mcp-Session-Id`
113
+ * response header. The Node transport's `sessionId` is a getter backed by an
114
+ * inner web-standard transport, so verify the write by reading back and fall
115
+ * back to writing the inner one. Returns whether the write stuck; never throws.
116
+ */
117
+ export function writeSessionIdToTransport(transport: unknown, token: string): boolean {
118
+ if (!transport || typeof transport !== 'object') {
119
+ return false
120
+ }
121
+ const outer = transport as { sessionId?: string; _webStandardTransport?: unknown }
122
+ try {
123
+ outer.sessionId = token
124
+ } catch {
125
+ // getter-only wrapper — try the inner transport below
126
+ }
127
+ if (outer.sessionId === token) {
128
+ return true
129
+ }
130
+ const inner = outer._webStandardTransport
131
+ if (inner && typeof inner === 'object') {
132
+ try {
133
+ ;(inner as { sessionId?: string }).sessionId = token
134
+ } catch {
135
+ // read-only inner transport — the final read-back decides
136
+ }
137
+ }
138
+ return outer.sessionId === token
139
+ }
140
+
141
+ // base64url without assuming Node: Buffer where available, TextEncoder/btoa on
142
+ // edge runtimes. Must survive non-ASCII client names.
143
+
144
+ function utf8ToBase64Url(input: string): string {
145
+ if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
146
+ return Buffer.from(input, 'utf8').toString('base64url')
147
+ }
148
+ const bytes = new TextEncoder().encode(input)
149
+ let binary = ''
150
+ for (let i = 0; i < bytes.length; i++) {
151
+ binary += String.fromCharCode(bytes[i])
152
+ }
153
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
154
+ }
155
+
156
+ function base64UrlToUtf8(input: string): string {
157
+ const normalized = input.replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '')
158
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
159
+ if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
160
+ return Buffer.from(padded, 'base64').toString('utf8')
161
+ }
162
+ const binary = atob(padded)
163
+ const bytes = new Uint8Array(binary.length)
164
+ for (let i = 0; i < binary.length; i++) {
165
+ bytes[i] = binary.charCodeAt(i)
166
+ }
167
+ return new TextDecoder().decode(bytes)
168
+ }
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import { version } from '../version'
6
7
  import type {
@@ -13,6 +14,7 @@ import type {
13
14
  import { INACTIVITY_TIMEOUT_IN_MINUTES } from './constants'
14
15
  import { deterministicPrefixedId, newPrefixedId } from './ids'
15
16
  import { getServerTrackingData, setServerTrackingData } from './internal'
17
+ import { decodeSessionId, readMcpSessionHeader } from './session-token'
16
18
 
17
19
  export function newSessionId(): string {
18
20
  return newPrefixedId('ses')
@@ -27,58 +29,58 @@ export function deriveSessionIdFromMCPSession(mcpSessionId: string): string {
27
29
  }
28
30
 
29
31
  /**
30
- * Resolves the session id for a request, preferring the MCP protocol sessionId
31
- * over an SDK-generated one so a transport-supplied session wins.
32
+ * Resolves the session id for a request: from the replayed session token, from
33
+ * the transport's MCP session id, or — when the request carries nothing — from
34
+ * this instance's memory. Also saves the token's client name/version so events
35
+ * built later in the request can use them (see getSessionInfo).
32
36
  */
33
- export function getServerSessionId(server: MCPServerLike, extra?: CompatibleRequestHandlerExtra): string {
37
+ export function getSessionId(server: MCPServerLike, extra?: CompatibleRequestHandlerExtra): string {
34
38
  const data = getServerTrackingData(server)
35
-
36
39
  if (!data) {
37
40
  throw new Error('Server tracking data not found')
38
41
  }
39
42
 
40
- const mcpSessionId = extra?.sessionId
41
-
42
- if (mcpSessionId) {
43
- data.sessionId = deriveSessionIdFromMCPSession(mcpSessionId)
44
- data.lastMcpSessionId = mcpSessionId
43
+ // Our token rides the `mcp-session-id` request header, which stateless
44
+ // transports ignore — so read it ourselves.
45
+ const sessionHeader = readMcpSessionHeader(extra?.requestInfo?.headers)
46
+ const token = decodeSessionId(sessionHeader)
47
+
48
+ let sessionId: string
49
+ if (token) {
50
+ // Token we minted at `initialize` (see session-token.ts).
51
+ data.sessionSource = 'token'
52
+ data.sessionInfo.clientName = token.clientName
53
+ data.sessionInfo.clientVersion = token.clientVersion
54
+ sessionId = token.sessionId
55
+ } else if (extra?.sessionId) {
56
+ // Session id issued by a stateful transport: hash it so the same MCP
57
+ // session maps to the same SDK session across restarts.
45
58
  data.sessionSource = 'mcp'
46
- setServerTrackingData(server, data)
47
- setLastActivity(server)
48
- return data.sessionId
59
+ sessionId = deriveSessionIdFromMCPSession(extra.sessionId)
60
+ } else {
61
+ sessionId = getSessionIdFromMemory(data)
49
62
  }
50
63
 
51
- // Once a session has been MCP-derived, keep that id even if a later request
52
- // arrives without the MCP sessionId, so the session doesn't fragment.
53
- if (data.sessionSource === 'mcp' && data.lastMcpSessionId) {
54
- setLastActivity(server)
55
- return data.sessionId
56
- }
64
+ data.sessionId = sessionId
65
+ data.lastActivity = new Date()
66
+ setServerTrackingData(server, data)
67
+ return sessionId
68
+ }
57
69
 
58
- // SDK-generated sessions roll over after an inactivity timeout.
59
- const now = Date.now()
70
+ /**
71
+ * Nothing replayed on this request: keep the id we already have. Only
72
+ * generated sessions roll over on inactivity — token/MCP ids live as long as
73
+ * the client replays them, and regenerating one would split the session.
74
+ */
75
+ function getSessionIdFromMemory(data: MCPAnalyticsData): string {
60
76
  const timeoutMs = INACTIVITY_TIMEOUT_IN_MINUTES * 60 * 1000
61
- if (now - data.lastActivity.getTime() > timeoutMs) {
62
- data.sessionId = newSessionId()
63
- data.sessionSource = 'generated'
64
- setServerTrackingData(server, data)
77
+ const isStale = Date.now() - data.lastActivity.getTime() > timeoutMs
78
+ if (data.sessionSource === 'generated' && isStale) {
79
+ return newSessionId()
65
80
  }
66
- setLastActivity(server)
67
-
68
81
  return data.sessionId
69
82
  }
70
83
 
71
- export function setLastActivity(server: MCPServerLike): void {
72
- const data = getServerTrackingData(server)
73
-
74
- if (!data) {
75
- throw new Error('Server tracking data not found')
76
- }
77
-
78
- data.lastActivity = new Date()
79
- setServerTrackingData(server, data)
80
- }
81
-
82
84
  /**
83
85
  * Builds the session metadata stamped onto an event. The caller passes the
84
86
  * session id resolved for *this* request so identity attribution can't be
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import type { PostHog } from 'posthog-node'
6
7
  import { uuidv7 } from '@posthog/core'
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import { type CallToolResult, type ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
6
7
  import { log } from './logger'
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import type { CompatibleRequestHandlerExtra, MCPAnalyticsData, MCPRequestLike, McpEvent } from '../types'
6
7
  import { resolveEventProperties } from './internal'
@@ -1,6 +1,7 @@
1
- // Portions of this file are derived from MCPCat/mcpcat-typescript-sdk
2
- // Copyright (c) 2025 MCPcat
3
- // Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
4
5
 
5
6
  import type { ErrorProperties, Event, StackFrame, McpEvent } from '../types'
6
7