@posthog/mcp 0.10.6 → 0.10.8
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/analytics-parameters.d.ts +7 -2
- package/dist/extensions/analytics-parameters.d.ts.map +1 -1
- package/dist/extensions/analytics-parameters.js +4 -2
- package/dist/extensions/analytics-parameters.mjs +4 -2
- package/dist/extensions/capture.d.ts +2 -2
- package/dist/extensions/capture.d.ts.map +1 -1
- package/dist/extensions/capture.js +2 -2
- package/dist/extensions/capture.mjs +2 -2
- package/dist/extensions/conversation-id.d.ts +37 -8
- package/dist/extensions/conversation-id.d.ts.map +1 -1
- package/dist/extensions/conversation-id.js +8 -13
- package/dist/extensions/conversation-id.mjs +8 -13
- package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
- package/dist/extensions/instrument-highlevel.js +1 -1
- package/dist/extensions/instrument-highlevel.mjs +1 -1
- package/dist/extensions/instrumentation.d.ts.map +1 -1
- package/dist/extensions/instrumentation.js +86 -42
- package/dist/extensions/instrumentation.mjs +88 -44
- package/dist/extensions/internal.d.ts +10 -2
- package/dist/extensions/internal.d.ts.map +1 -1
- package/dist/extensions/internal.js +25 -9
- package/dist/extensions/internal.mjs +19 -6
- package/dist/extensions/output-instructions.d.ts +70 -0
- package/dist/extensions/output-instructions.d.ts.map +1 -0
- package/dist/extensions/output-instructions.js +131 -0
- package/dist/extensions/output-instructions.mjs +82 -0
- package/dist/extensions/session.d.ts +26 -5
- package/dist/extensions/session.d.ts.map +1 -1
- package/dist/extensions/session.js +35 -15
- package/dist/extensions/session.mjs +33 -16
- package/dist/types.d.ts +23 -1
- 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 +3 -3
- package/src/extensions/analytics-parameters.ts +15 -2
- package/src/extensions/capture.ts +13 -6
- package/src/extensions/conversation-id.ts +51 -29
- package/src/extensions/instrument-highlevel.ts +3 -1
- package/src/extensions/instrumentation.ts +151 -60
- package/src/extensions/internal.ts +30 -7
- package/src/extensions/output-instructions.ts +231 -0
- package/src/extensions/session.ts +128 -29
- package/src/types.ts +23 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { LoggerFn } from './logger'
|
|
2
|
+
import { log } from './logger'
|
|
3
|
+
import type { AnalyticsInjectableJsonSchema } from './analytics-parameters'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `conversation_id` session handle reaches the agent as a text block appended to the
|
|
7
|
+
* tool result. That block is invisible to any client that reads
|
|
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:
|
|
14
|
+
*
|
|
15
|
+
* 1. declare `_mcp_instructions` on the tool's advertised `outputSchema`
|
|
16
|
+
* 2. write it into the result's `structuredContent`
|
|
17
|
+
*
|
|
18
|
+
* The declaration is what makes the write safe. The MCP client ajv-validates
|
|
19
|
+
* `structuredContent` against the schema from `tools/list`, and
|
|
20
|
+
* `zod-to-json-schema` emits `additionalProperties: false` for a plain
|
|
21
|
+
* `z.object`, so an undeclared key is not ignored — it fails the entire tool
|
|
22
|
+
* result. Only tools that got the declaration are ever written to.
|
|
23
|
+
*/
|
|
24
|
+
export const MCP_INSTRUCTIONS_KEY = '_mcp_instructions'
|
|
25
|
+
|
|
26
|
+
const INSTRUCTIONS_FIELD_DESCRIPTION =
|
|
27
|
+
'Server-issued handles for this conversation, and what to do with them. Read and follow.'
|
|
28
|
+
|
|
29
|
+
const CONVERSATION_ID_FIELD_DESCRIPTION =
|
|
30
|
+
'Echo this exact value as the conversation_id argument on every subsequent tool call.'
|
|
31
|
+
|
|
32
|
+
export interface OutputInstructionsInjectableTool {
|
|
33
|
+
name?: string
|
|
34
|
+
outputSchema?: unknown
|
|
35
|
+
[key: string]: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when we can safely declare {@link MCP_INSTRUCTIONS_KEY} on this tool's
|
|
40
|
+
* advertised output schema.
|
|
41
|
+
*
|
|
42
|
+
* Requires a plain-object schema we can extend. A tool with no `outputSchema`
|
|
43
|
+
* has nothing to mirror into and keeps working through `content`; a composed
|
|
44
|
+
* schema (`oneOf`/`allOf`/`anyOf`/`$ref`) has no single `properties` bag to add
|
|
45
|
+
* to. Both stay content-only, matching the policy on the input side.
|
|
46
|
+
*/
|
|
47
|
+
export function canDeclareOutputInstructions(outputSchema: unknown): boolean {
|
|
48
|
+
if (!outputSchema || typeof outputSchema !== 'object') {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Only the JSON Schema a tool advertises can answer this. A Zod schema or raw
|
|
53
|
+
// shape — what the high-level registry stores — has no `properties` bag, so
|
|
54
|
+
// every check below would pass vacuously and report `true` for a tool whose
|
|
55
|
+
// schema we actually skipped. Refuse rather than guess: callers holding a Zod
|
|
56
|
+
// value must read the recorded answer off the ownership registry instead.
|
|
57
|
+
if (isSchemaObjectLike(outputSchema)) {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const schema = outputSchema as AnalyticsInjectableJsonSchema
|
|
62
|
+
if (schema.$ref || schema.oneOf || schema.allOf || schema.anyOf) {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// `properties` must be a plain object if it is present at all. A tool that
|
|
67
|
+
// advertises something else is malformed, and harmlessly so until we try to
|
|
68
|
+
// declare into it — assigning a key to a boolean throws, and that throw
|
|
69
|
+
// surfaces inside the `tools/list` wrapper, failing the entire listing over a
|
|
70
|
+
// schema we only wanted to annotate.
|
|
71
|
+
const { properties } = schema
|
|
72
|
+
if (
|
|
73
|
+
properties !== undefined &&
|
|
74
|
+
(typeof properties !== 'object' || properties === null || Array.isArray(properties))
|
|
75
|
+
) {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
return !properties || !Object.prototype.hasOwnProperty.call(properties, MCP_INSTRUCTIONS_KEY)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A single Zod schema — the internals every version brands its instances with. */
|
|
82
|
+
function isZodLike(entry: unknown): boolean {
|
|
83
|
+
return !!entry && typeof entry === 'object' && ('_def' in entry || '_zod' in entry || '~standard' in entry)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether this is a JSON Schema, judged on the *value* of each marker rather
|
|
88
|
+
* than the key alone — a raw Zod shape is free to name a field `type` or
|
|
89
|
+
* `properties`, but its values are Zod schemas, not a string and a property bag.
|
|
90
|
+
*/
|
|
91
|
+
function looksLikeJsonSchema(candidate: Record<string, unknown>): boolean {
|
|
92
|
+
if (typeof candidate.type === 'string' || typeof candidate.$ref === 'string') {
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(candidate.oneOf) || Array.isArray(candidate.allOf) || Array.isArray(candidate.anyOf)) {
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
const { properties } = candidate
|
|
99
|
+
return !!properties && typeof properties === 'object' && !isZodLike(properties)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A Zod schema, or a raw shape whose values are Zod schemas. */
|
|
103
|
+
function isSchemaObjectLike(value: unknown): boolean {
|
|
104
|
+
const candidate = value as Record<string, unknown>
|
|
105
|
+
if (isZodLike(candidate)) {
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Settle "is this a JSON Schema" before scanning the values, because a schema
|
|
110
|
+
// may legitimately declare a property named `_def` — and the scan below would
|
|
111
|
+
// read that property's schema object as a Zod value and skip a tool we could
|
|
112
|
+
// have annotated perfectly well.
|
|
113
|
+
if (looksLikeJsonSchema(candidate)) {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
return Object.values(candidate).some(isZodLike)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns a copy of the tool with an optional {@link MCP_INSTRUCTIONS_KEY}
|
|
121
|
+
* property added to its output schema, or the tool untouched when it has no
|
|
122
|
+
* output schema to extend.
|
|
123
|
+
*
|
|
124
|
+
* The property is never added to `required` — a result without it must stay
|
|
125
|
+
* valid, since every tool result predating this change lacks it.
|
|
126
|
+
*/
|
|
127
|
+
export function addInstructionsToOutputSchema<TTool extends OutputInstructionsInjectableTool>(
|
|
128
|
+
tool: TTool,
|
|
129
|
+
logger: LoggerFn = log
|
|
130
|
+
): TTool {
|
|
131
|
+
const toolName = tool.name || 'unknown'
|
|
132
|
+
const original = tool.outputSchema
|
|
133
|
+
|
|
134
|
+
// No output schema means the client reads `content`, where the session handle already
|
|
135
|
+
// rides. Nothing to declare, and nothing broken.
|
|
136
|
+
if (!original || typeof original !== 'object') {
|
|
137
|
+
return tool
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!canDeclareOutputInstructions(original)) {
|
|
141
|
+
const schema = original as AnalyticsInjectableJsonSchema
|
|
142
|
+
if (schema.properties && Object.prototype.hasOwnProperty.call(schema.properties, MCP_INSTRUCTIONS_KEY)) {
|
|
143
|
+
logger(
|
|
144
|
+
`WARN: Tool "${toolName}" already declares '${MCP_INSTRUCTIONS_KEY}' in its output schema. Leaving it alone.`
|
|
145
|
+
)
|
|
146
|
+
} else {
|
|
147
|
+
logger(
|
|
148
|
+
`WARN: Tool "${toolName}" has a complex output schema (oneOf/allOf/anyOf/$ref). Skipping '${MCP_INSTRUCTIONS_KEY}' declaration; its session handle stays content-only.`
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
return tool
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const modifiedTool = { ...tool }
|
|
155
|
+
// Deep copy: the server may reuse or freeze the schema object it handed us.
|
|
156
|
+
const outputSchema = JSON.parse(JSON.stringify(original)) as AnalyticsInjectableJsonSchema
|
|
157
|
+
|
|
158
|
+
if (!outputSchema.properties) {
|
|
159
|
+
outputSchema.properties = {}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
outputSchema.properties[MCP_INSTRUCTIONS_KEY] = {
|
|
163
|
+
type: 'object',
|
|
164
|
+
description: INSTRUCTIONS_FIELD_DESCRIPTION,
|
|
165
|
+
properties: {
|
|
166
|
+
conversation_id: {
|
|
167
|
+
type: 'string',
|
|
168
|
+
description: CONVERSATION_ID_FIELD_DESCRIPTION,
|
|
169
|
+
},
|
|
170
|
+
instructions: { type: 'string' },
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
modifiedTool.outputSchema = outputSchema
|
|
175
|
+
return modifiedTool
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Declares {@link MCP_INSTRUCTIONS_KEY} across a tool listing. Tools without an
|
|
180
|
+
* output schema, and tools whose schema we cannot extend, pass through unchanged.
|
|
181
|
+
*/
|
|
182
|
+
export function addInstructionsToOutputSchemas<TTool extends OutputInstructionsInjectableTool>(
|
|
183
|
+
tools: TTool[],
|
|
184
|
+
logger: LoggerFn = log
|
|
185
|
+
): TTool[] {
|
|
186
|
+
return tools.map((tool) => addInstructionsToOutputSchema(tool, logger))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface ConversationInstructions {
|
|
190
|
+
conversation_id: string
|
|
191
|
+
instructions: string
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const ECHO_INSTRUCTION = 'Send this conversation_id as an argument on every subsequent tool call in this conversation.'
|
|
195
|
+
|
|
196
|
+
/** The payload mirrored into `structuredContent` for a tool we declared the key on. */
|
|
197
|
+
export function buildConversationInstructions(conversationId: string): ConversationInstructions {
|
|
198
|
+
return { conversation_id: conversationId, instructions: ECHO_INSTRUCTION }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Adds {@link MCP_INSTRUCTIONS_KEY} to a result's `structuredContent`, which is
|
|
203
|
+
* where clients look once a tool declares an `outputSchema` — the `content` text
|
|
204
|
+
* block carrying the session handle is invisible to them.
|
|
205
|
+
*
|
|
206
|
+
* Unlike the text block, this rides *every* response rather than only the one
|
|
207
|
+
* that minted the session handle, so an agent that drops it can read it back.
|
|
208
|
+
*
|
|
209
|
+
* Returns the result untouched when there is no plain-object `structuredContent`
|
|
210
|
+
* to extend, or when the tool already produced its own key — customer data wins.
|
|
211
|
+
* Never mutates the input.
|
|
212
|
+
*/
|
|
213
|
+
export function mirrorInstructionsIntoStructuredContent(result: unknown, conversationId: string): unknown {
|
|
214
|
+
if (!result || typeof result !== 'object') {
|
|
215
|
+
return result
|
|
216
|
+
}
|
|
217
|
+
const structuredContent = (result as { structuredContent?: unknown }).structuredContent
|
|
218
|
+
if (!structuredContent || typeof structuredContent !== 'object' || Array.isArray(structuredContent)) {
|
|
219
|
+
return result
|
|
220
|
+
}
|
|
221
|
+
if (Object.prototype.hasOwnProperty.call(structuredContent, MCP_INSTRUCTIONS_KEY)) {
|
|
222
|
+
return result
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
...result,
|
|
226
|
+
structuredContent: {
|
|
227
|
+
...structuredContent,
|
|
228
|
+
[MCP_INSTRUCTIONS_KEY]: buildConversationInstructions(conversationId),
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -15,6 +15,7 @@ import { INACTIVITY_TIMEOUT_IN_MINUTES } from './constants'
|
|
|
15
15
|
import { deterministicPrefixedId, newPrefixedId } from './ids'
|
|
16
16
|
import { getServerTrackingData, setServerTrackingData } from './internal'
|
|
17
17
|
import { decodeSessionId, readMcpSessionHeader } from './session-token'
|
|
18
|
+
import type { SessionTokenPayload } from './session-token'
|
|
18
19
|
|
|
19
20
|
export function newSessionId(): string {
|
|
20
21
|
return newPrefixedId('ses')
|
|
@@ -29,39 +30,77 @@ export function deriveSessionIdFromMCPSession(mcpSessionId: string): string {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Derives the SDK session id from the agent's conversation handle. Deterministic
|
|
34
|
+
* and unsalted on purpose: two pods that never met must agree on the session, and
|
|
35
|
+
* the 2026-07-28 revision leaves them no shared state to agree through.
|
|
36
|
+
*
|
|
37
|
+
* Hashed rather than used verbatim so an MCP session can never collide with a
|
|
38
|
+
* Session Replay id — a bare uuidv7 would render a "View recording" button that
|
|
39
|
+
* resolves to nothing.
|
|
40
|
+
*
|
|
41
|
+
* Exported because this *is* the cross-SDK contract: posthog-python has to
|
|
42
|
+
* reproduce it byte for byte or the same conversation splits into two sessions
|
|
43
|
+
* depending on which SDK served the call.
|
|
36
44
|
*/
|
|
37
|
-
export function
|
|
45
|
+
export function deriveSessionIdFromConversation(conversationId: string): string {
|
|
46
|
+
return deterministicPrefixedId('ses', conversationId)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolves the session id for a request. Three steps, first match wins:
|
|
51
|
+
*
|
|
52
|
+
* 1. the agent carried a `conversation_id` tool argument — 2026-07-28
|
|
53
|
+
* 2. the request carried a session id — 2025-11-25
|
|
54
|
+
* 3. nothing was carried, so reuse this instance's own id — stdio
|
|
55
|
+
*
|
|
56
|
+
* The split mirrors the two protocol revisions. 2026-07-28 removed protocol-level
|
|
57
|
+
* sessions, so the only thing that can carry a session across calls is the agent
|
|
58
|
+
* itself (1). 2025-11-25 kept the session on the connection, so the request
|
|
59
|
+
* carries it (2). 3 is for stdio, where neither applies — there is no
|
|
60
|
+
* HTTP request to carry anything, and no agent handle unless the host opted in.
|
|
61
|
+
*/
|
|
62
|
+
export function getSessionId(
|
|
63
|
+
server: MCPServerLike,
|
|
64
|
+
extra?: CompatibleRequestHandlerExtra,
|
|
65
|
+
conversationId?: string
|
|
66
|
+
): string {
|
|
38
67
|
const data = getServerTrackingData(server)
|
|
39
68
|
if (!data) {
|
|
40
69
|
throw new Error('Server tracking data not found')
|
|
41
70
|
}
|
|
42
71
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
72
|
+
// 1. The agent's conversation handle. It outranks both steps below because it
|
|
73
|
+
// is the only id that survives reconnects, restarts, and the per-request
|
|
74
|
+
// server instances the 2026-07-28 revision introduces. Hashed rather than used
|
|
75
|
+
// verbatim so it can never collide with Session Replay ids.
|
|
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.
|
|
85
|
+
if (conversationId) {
|
|
86
|
+
// The handle decides the *session*, but the request may still carry our token,
|
|
87
|
+
// and on a stateless instance that never processed `initialize` its payload is
|
|
88
|
+
// the only place client identity exists. Restore that before returning: the
|
|
89
|
+
// early return is about not persisting a session, not about ignoring what the
|
|
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.
|
|
92
|
+
applyTokenClientIdentity(data, extra)
|
|
93
|
+
return deriveSessionIdFromConversation(conversationId)
|
|
63
94
|
}
|
|
64
95
|
|
|
96
|
+
// 2. A session id the request itself carried (undefined if it carried none).
|
|
97
|
+
const carriedByRequest = readSessionIdFromRequest(data, extra)
|
|
98
|
+
|
|
99
|
+
// 3. Nothing carried, so fall back to the id this instance already holds.
|
|
100
|
+
const sessionId = carriedByRequest ?? getSessionIdFromMemory(data)
|
|
101
|
+
|
|
102
|
+
// 2 and 3 are connection-scoped, so their result is remembered for the
|
|
103
|
+
// next request on this instance. 1 never reaches here.
|
|
65
104
|
data.sessionId = sessionId
|
|
66
105
|
data.lastActivity = new Date()
|
|
67
106
|
setServerTrackingData(server, data)
|
|
@@ -69,9 +108,69 @@ export function getSessionId(server: MCPServerLike, extra?: CompatibleRequestHan
|
|
|
69
108
|
}
|
|
70
109
|
|
|
71
110
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
111
|
+
* Restores the client name/version and protocol version baked into our token at
|
|
112
|
+
* mint time, and hands the decoded token back so the caller can also take the
|
|
113
|
+
* session id from it.
|
|
114
|
+
*
|
|
115
|
+
* Split out from step 2 because the two halves of the token have different
|
|
116
|
+
* scopes. The session id it carries is per-chat, so a conversation-anchored
|
|
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.
|
|
121
|
+
*/
|
|
122
|
+
function applyTokenClientIdentity(
|
|
123
|
+
data: MCPAnalyticsData,
|
|
124
|
+
extra?: CompatibleRequestHandlerExtra
|
|
125
|
+
): SessionTokenPayload | undefined {
|
|
126
|
+
const token = decodeSessionId(readMcpSessionHeader(extra?.requestInfo?.headers))
|
|
127
|
+
if (!token) {
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
data.sessionInfo.clientName = token.clientName
|
|
131
|
+
data.sessionInfo.clientVersion = token.clientVersion
|
|
132
|
+
data.sessionInfo.protocolVersion = token.protocolVersion
|
|
133
|
+
return token
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 2. The session id a request carried. Two sources, tried in order: our own token
|
|
138
|
+
* on the `mcp-session-id` header, then the raw session id a stateful transport
|
|
139
|
+
* issued. Returns undefined when the request carried neither, which is what
|
|
140
|
+
* sends the caller on to 3.
|
|
141
|
+
*
|
|
142
|
+
* Both are 2025-11-25 mechanisms. The 2026-07-28 revision removed that header
|
|
143
|
+
* outright — servers MUST NOT mint or echo it — so this entire step becomes
|
|
144
|
+
* legacy-only once era detection lands.
|
|
145
|
+
*/
|
|
146
|
+
function readSessionIdFromRequest(data: MCPAnalyticsData, extra?: CompatibleRequestHandlerExtra): string | undefined {
|
|
147
|
+
// 2a. A token we minted at `initialize` and the client replayed. It rides the
|
|
148
|
+
// `mcp-session-id` header, which stateless transports don't surface as
|
|
149
|
+
// extra.sessionId, so read the header ourselves.
|
|
150
|
+
const token = applyTokenClientIdentity(data, extra)
|
|
151
|
+
if (token) {
|
|
152
|
+
data.sessionSource = 'token'
|
|
153
|
+
return token.sessionId
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 2b. No token, but a stateful transport issued its own session id. Hash it so
|
|
157
|
+
// the same MCP session maps to the same SDK session across restarts.
|
|
158
|
+
if (extra?.sessionId) {
|
|
159
|
+
data.sessionSource = 'mcp'
|
|
160
|
+
return deriveSessionIdFromMCPSession(extra.sessionId)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 3. The request carried nothing, so keep the id this instance already holds —
|
|
168
|
+
* minted once at `instrument()`. This is what groups a stdio server's calls,
|
|
169
|
+
* where there is no header and no transport session to read.
|
|
170
|
+
*
|
|
171
|
+
* Only self-generated ids roll over on inactivity. Token and transport ids live
|
|
172
|
+
* as long as the client replays them, so regenerating one would split a session
|
|
173
|
+
* that is still very much alive.
|
|
75
174
|
*/
|
|
76
175
|
function getSessionIdFromMemory(data: MCPAnalyticsData): string {
|
|
77
176
|
const timeoutMs = INACTIVITY_TIMEOUT_IN_MINUTES * 60 * 1000
|
|
@@ -125,5 +224,5 @@ export function getSessionInfo(
|
|
|
125
224
|
|
|
126
225
|
data.sessionInfo = sessionInfo
|
|
127
226
|
setServerTrackingData(server, data)
|
|
128
|
-
return
|
|
227
|
+
return { ...sessionInfo }
|
|
129
228
|
}
|
package/src/types.ts
CHANGED
|
@@ -57,7 +57,21 @@ export interface MCPAnalyticsOptions {
|
|
|
57
57
|
* detected under the same name.
|
|
58
58
|
*/
|
|
59
59
|
missingCapabilityToolName?: string
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Opt in to session correlation for the MCP **2026-07-28** revision, which removed
|
|
62
|
+
* protocol-level sessions: no `initialize`, no `mcp-session-id` header, and a fresh
|
|
63
|
+
* server instance per HTTP request. With none of those left to anchor on, the only
|
|
64
|
+
* thing that can carry a session across calls is the agent itself.
|
|
65
|
+
*
|
|
66
|
+
* Turning this on injects a `conversation_id` parameter into every tool, mints one
|
|
67
|
+
* on the first call, asks the agent to echo it back, and derives `$session_id` from
|
|
68
|
+
* that handle — so calls correlate across reconnects, restarts, and per-request
|
|
69
|
+
* instances.
|
|
70
|
+
*
|
|
71
|
+
* Off by default, and fully inert when off: no parameter is injected, no schema is
|
|
72
|
+
* touched, no prompt-back is appended, and `$session_id` resolves exactly as it did
|
|
73
|
+
* before (the request's own session id, else this instance's).
|
|
74
|
+
*/
|
|
61
75
|
enableConversationId?: boolean
|
|
62
76
|
/**
|
|
63
77
|
* Emit a `$exception` event alongside any failed tool call. Defaults to `true`.
|
|
@@ -123,6 +137,8 @@ export type RegisteredTool = {
|
|
|
123
137
|
/** MCP tool `_meta` block (spec-allowed arbitrary metadata, e.g. `category`). */
|
|
124
138
|
_meta?: Record<string, unknown>
|
|
125
139
|
inputSchema?: unknown
|
|
140
|
+
/** Present when the tool was registered with a declared output schema. */
|
|
141
|
+
outputSchema?: unknown
|
|
126
142
|
update?: (...args: unknown[]) => unknown
|
|
127
143
|
} & ({ callback: ToolCallback; handler?: never } | { handler: ToolCallback; callback?: never })
|
|
128
144
|
|
|
@@ -274,6 +290,12 @@ export interface SessionInfo {
|
|
|
274
290
|
export interface AnalyticsParameterOwnership {
|
|
275
291
|
context: boolean
|
|
276
292
|
conversationId: boolean
|
|
293
|
+
/**
|
|
294
|
+
* True when we declared `_mcp_instructions` on this tool's advertised output
|
|
295
|
+
* schema, so writing that key into `structuredContent` will validate. False
|
|
296
|
+
* for tools with no output schema, or one we could not extend.
|
|
297
|
+
*/
|
|
298
|
+
outputInstructions: boolean
|
|
277
299
|
}
|
|
278
300
|
|
|
279
301
|
export interface MCPAnalyticsData {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '0.10.
|
|
1
|
+
export const version = '0.10.8'
|