@posthog/mcp 0.10.7 → 0.10.9

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 (51) hide show
  1. package/dist/extensions/analytics-parameters.d.ts +7 -2
  2. package/dist/extensions/analytics-parameters.d.ts.map +1 -1
  3. package/dist/extensions/analytics-parameters.js +4 -2
  4. package/dist/extensions/analytics-parameters.mjs +4 -2
  5. package/dist/extensions/conversation-id.d.ts +37 -8
  6. package/dist/extensions/conversation-id.d.ts.map +1 -1
  7. package/dist/extensions/conversation-id.js +8 -13
  8. package/dist/extensions/conversation-id.mjs +8 -13
  9. package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
  10. package/dist/extensions/instrument-highlevel.js +1 -1
  11. package/dist/extensions/instrument-highlevel.mjs +1 -1
  12. package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
  13. package/dist/extensions/instrument-lowlevel.js +6 -5
  14. package/dist/extensions/instrument-lowlevel.mjs +7 -6
  15. package/dist/extensions/instrumentation.d.ts +22 -0
  16. package/dist/extensions/instrumentation.d.ts.map +1 -1
  17. package/dist/extensions/instrumentation.js +48 -23
  18. package/dist/extensions/instrumentation.mjs +46 -24
  19. package/dist/extensions/internal.d.ts +8 -1
  20. package/dist/extensions/internal.d.ts.map +1 -1
  21. package/dist/extensions/internal.js +2 -2
  22. package/dist/extensions/internal.mjs +2 -2
  23. package/dist/extensions/mcp-sdk-compat.d.ts +10 -0
  24. package/dist/extensions/mcp-sdk-compat.d.ts.map +1 -1
  25. package/dist/extensions/mcp-sdk-compat.js +11 -2
  26. package/dist/extensions/mcp-sdk-compat.mjs +7 -1
  27. package/dist/extensions/output-instructions.d.ts +70 -0
  28. package/dist/extensions/output-instructions.d.ts.map +1 -0
  29. package/dist/extensions/output-instructions.js +131 -0
  30. package/dist/extensions/output-instructions.mjs +82 -0
  31. package/dist/extensions/session.d.ts +26 -5
  32. package/dist/extensions/session.d.ts.map +1 -1
  33. package/dist/extensions/session.js +32 -14
  34. package/dist/extensions/session.mjs +30 -15
  35. package/dist/types.d.ts +23 -1
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/dist/version.mjs +1 -1
  40. package/package.json +4 -4
  41. package/src/extensions/analytics-parameters.ts +15 -2
  42. package/src/extensions/conversation-id.ts +51 -29
  43. package/src/extensions/instrument-highlevel.ts +3 -1
  44. package/src/extensions/instrument-lowlevel.ts +14 -9
  45. package/src/extensions/instrumentation.ts +130 -38
  46. package/src/extensions/internal.ts +10 -2
  47. package/src/extensions/mcp-sdk-compat.ts +18 -0
  48. package/src/extensions/output-instructions.ts +231 -0
  49. package/src/extensions/session.ts +127 -28
  50. package/src/types.ts +23 -1
  51. 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 { addInstructionsToOutputSchemas, mirrorInstructionsIntoStructuredContent } from './output-instructions'
27
28
  import { captureEvent } from './capture'
28
29
  import { MCPAnalyticsEventType } from './event-types'
29
30
  import { captureException } from './exceptions'
@@ -31,7 +32,7 @@ import { resolveToolCallIntent, setEventIntent, setExplicitContextIntent } from
31
32
  import { getServerTrackingData, handleIdentify, setServerTrackingData, withIdentity } from './internal'
32
33
  import type { LoggerFn } from './logger'
33
34
  import { buildCapturedMcpParameters } from './mcp-payloads'
34
- import { getLiteralValue, getObjectShape } from './mcp-sdk-compat'
35
+ import { readRequestHandlerMethod } from './mcp-sdk-compat'
35
36
  import { getSessionId, getSessionInfo, newSessionId } from './session'
36
37
  import { encodeSessionId, readMcpSessionHeader, writeSessionIdToTransport } from './session-token'
37
38
  import { getReportMissingToolDescriptor, resolveMissingCapabilityToolName } from './tools'
@@ -106,14 +107,7 @@ export async function captureToolCall(params: TraceToolCallParams): Promise<unkn
106
107
  parameterOwnership,
107
108
  resolvedEventType === MCPAnalyticsEventType.mcpMissingCapability
108
109
  )
109
- const conversation = resolveConversationId(
110
- ownership.conversationId,
111
- request.params?.arguments,
112
- request.params?.name,
113
- resolvedEventType === MCPAnalyticsEventType.mcpMissingCapability
114
- ? resolveMissingCapabilityToolName(data.options)
115
- : ''
116
- )
110
+ const conversation = resolveConversationId(ownership.conversationId, request.params?.arguments)
117
111
  const downstreamRequest = cloneRequestWithoutOwnedAnalyticsArguments(request, ownership)
118
112
 
119
113
  // Prepare the event in isolation: if identity/metadata/intent resolution
@@ -142,7 +136,7 @@ export async function captureToolCall(params: TraceToolCallParams): Promise<unkn
142
136
  throw error
143
137
  }
144
138
 
145
- const finalResult = applyConversationPromptBack(preparedEvent?.event ?? null, result, conversation)
139
+ const finalResult = applyConversationInstructions(preparedEvent?.event ?? null, result, conversation, ownership)
146
140
  publishSuccessfulToolEvent(server, preparedEvent, finalResult, startTime, data.logger, takeCapturedError)
147
141
  return finalResult
148
142
  }
@@ -158,11 +152,28 @@ function getActiveAnalyticsParameterOwnership(
158
152
  override: AnalyticsParameterOwnership | undefined,
159
153
  isMissingCapabilityTool: boolean
160
154
  ): AnalyticsParameterOwnership {
161
- const ownership = override ?? (toolName ? data.toolAnalyticsParameterOwnership.get(toolName) : undefined)
155
+ const listed = toolName ? data.toolAnalyticsParameterOwnership.get(toolName) : undefined
156
+ const ownership = override ?? listed
162
157
  return {
163
158
  context: !isMissingCapabilityTool && isContextEnabled(data.options.context) && ownership?.context === true,
164
- conversationId:
165
- !isMissingCapabilityTool && data.options.enableConversationId === true && ownership?.conversationId === true,
159
+ conversationId: data.options.enableConversationId === true && ownership?.conversationId === true,
160
+ // Deliberately read off `listed`, never the override. This asks whether
161
+ // `tools/list` actually declared `_mcp_instructions`, and only the advertised
162
+ // JSON Schema can answer it — an override is built from the live registry,
163
+ // which on the high-level path holds Zod.
164
+ //
165
+ // The cache is per-instance, so this is not merely "before the first
166
+ // `tools/list`": an instance that never serves a listing never writes the
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.
176
+ outputInstructions: data.options.enableConversationId === true && listed?.outputInstructions === true,
166
177
  }
167
178
  }
168
179
 
@@ -196,11 +207,19 @@ async function prepareToolCallEvent(
196
207
  eventType: MCPAnalyticsEventType
197
208
  ): Promise<PreparedToolEvent | null> {
198
209
  try {
199
- const sessionId = getSessionId(server, extra)
210
+ const sessionId = getSessionId(server, extra, conversation.conversationId)
200
211
  // Snapshot token/client/protocol metadata synchronously, before identify or
201
212
  // metadata callbacks can yield and let another request replace shared state.
202
213
  const sessionInfo = getSessionInfo(server, data, sessionId)
203
- const identity = await handleIdentify(server, data, sessionId, request, sessionInfo, extra)
214
+ const identity = await handleIdentify(
215
+ server,
216
+ data,
217
+ sessionId,
218
+ request,
219
+ sessionInfo,
220
+ extra,
221
+ !!conversation.conversationId
222
+ )
204
223
  const requestAttribution = withIdentity(sessionInfo, identity)
205
224
 
206
225
  const toolName = request.params?.name
@@ -231,25 +250,51 @@ async function prepareToolCallEvent(
231
250
  }
232
251
 
233
252
  /**
234
- * When we minted a conversation id, append the prompt-back so the agent echoes
235
- * it on subsequent calls. If the result can't carry it, clear the id off the
236
- * event so analytics doesn't show an orphan the agent never received.
253
+ * Delivers the conversation session handle back to the agent, over both channels a tool
254
+ * result has:
255
+ *
256
+ * - `structuredContent`, on *every* response for a tool whose output schema we
257
+ * declared the key on. Clients that read structured results never see the text
258
+ * block, and re-sending it each time lets an agent that dropped the session handle read
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.
237
266
  */
238
- function applyConversationPromptBack(
267
+ function applyConversationInstructions(
239
268
  event: McpEvent | null,
240
269
  result: unknown,
241
- conversation: ConversationIdResolution
270
+ conversation: ConversationIdResolution,
271
+ ownership: AnalyticsParameterOwnership
242
272
  ): unknown {
243
- if (!conversation.minted) {
273
+ const conversationId = conversation.conversationId
274
+ if (!conversationId) {
244
275
  return result
245
276
  }
246
- if (canInjectConversationIdPromptBack(result)) {
247
- return injectConversationIdPromptBack(result, conversation.conversationId)
277
+
278
+ let updated = result
279
+ let delivered = false
280
+
281
+ if (ownership.outputInstructions) {
282
+ const mirrored = mirrorInstructionsIntoStructuredContent(updated, conversationId)
283
+ delivered = mirrored !== updated
284
+ updated = mirrored
285
+ }
286
+
287
+ if (conversation.minted && canInjectConversationIdPromptBack(updated)) {
288
+ updated = injectConversationIdPromptBack(updated, conversationId)
289
+ delivered = true
248
290
  }
249
- if (event) {
291
+
292
+ // Only a minted session handle can be lost this way — one the agent supplied, it has.
293
+ if (!delivered && conversation.minted && event) {
250
294
  event.conversationId = undefined
251
295
  }
252
- return result
296
+
297
+ return updated
253
298
  }
254
299
 
255
300
  function publishSuccessfulToolEvent(
@@ -341,6 +386,37 @@ function rememberOriginalRequestHandler(
341
386
  handlers.set(handlerName, originalHandler)
342
387
  }
343
388
 
389
+ /**
390
+ * Registers a synthetic fallback handler for `handlerName`, already wrapped in
391
+ * `patch`, by writing straight into `_requestHandlers` instead of going through
392
+ * `setRequestHandler`.
393
+ *
394
+ * Bypassing the SDK setter is deliberate, on three counts:
395
+ *
396
+ * - **Capability assertion.** `setRequestHandler` refuses a method the server
397
+ * never declared a capability for, so instrumenting a low-level server built
398
+ * without `capabilities.tools` used to throw `Server does not support tools`
399
+ * and leave instrumentation half-applied. Our fallback is not a capability the
400
+ * server offers — it exists only so a call for a tool nobody claims is still
401
+ * captured — so the assertion has nothing to protect here.
402
+ * - **Schema validation.** The setter also wraps the handler in request/result
403
+ * parsing, which a handler that can only ever throw `Unknown tool` never needs.
404
+ * - **Portability.** The setter's first argument is a Zod schema on SDK v1 and a
405
+ * method string on v2; the map key is the same string on both, so this is the
406
+ * one registration form that does not need to know which major it is talking
407
+ * to — and it drops the last runtime `@modelcontextprotocol/sdk` import from
408
+ * the shipped bundle.
409
+ */
410
+ export function registerFallbackRequestHandler(
411
+ server: MCPServerLike,
412
+ handlerName: string,
413
+ fallbackHandler: MCPRequestHandler,
414
+ patch: HandlerPatch
415
+ ): void {
416
+ rememberOriginalRequestHandler(server, handlerName, fallbackHandler)
417
+ server._requestHandlers.set(handlerName, (request, extra) => patch(server, fallbackHandler, request, extra))
418
+ }
419
+
344
420
  export function patchRequestHandlers(server: MCPServerLike, patches: Record<string, HandlerPatch>): void {
345
421
  // Monkey patch existing handlers.
346
422
  for (const [handlerName, patch] of Object.entries(patches)) {
@@ -352,18 +428,31 @@ export function patchRequestHandlers(server: MCPServerLike, patches: Record<stri
352
428
  }
353
429
 
354
430
  // Monkey patch dynamically added handlers (registered after instrument()).
355
- const originalSetRequestHandler = server.setRequestHandler.bind(server)
356
- server.setRequestHandler = ((requestSchema: unknown, originalHandler: MCPRequestHandler) => {
357
- const shape = getObjectShape(requestSchema)
358
- const handlerName = shape?.method ? getLiteralValue(shape.method) : undefined
359
- const patch = typeof handlerName === 'string' ? patches[handlerName] : undefined
360
- if (!patch || typeof handlerName !== 'string') {
361
- return originalSetRequestHandler(requestSchema, originalHandler)
431
+ //
432
+ // Variadic, and every argument is forwarded verbatim. The registration form is
433
+ // the SDK's business, not ours — only *that* a registration happened is ours.
434
+ // SDK v2 has a three-argument form for custom methods,
435
+ // `setRequestHandler(method, { params, result }, handler)`, and a two-parameter
436
+ // wrapper drops the handler: the SDK then sees the schemas object where the
437
+ // handler should be and throws `setRequestHandler: handler is required`, which
438
+ // takes down the host server rather than just our instrumentation.
439
+ const originalSetRequestHandler = server.setRequestHandler.bind(server) as (...args: unknown[]) => unknown
440
+ server.setRequestHandler = ((...args: unknown[]) => {
441
+ const handlerName = readRequestHandlerMethod(args[0])
442
+ // `hasOwnProperty`, not a bare index: `handlerName` is now an arbitrary
443
+ // caller-supplied string, and a custom method named `toString` would
444
+ // otherwise resolve to an inherited function and be treated as a patch.
445
+ const patch =
446
+ handlerName !== undefined && Object.prototype.hasOwnProperty.call(patches, handlerName)
447
+ ? patches[handlerName]
448
+ : undefined
449
+ if (handlerName === undefined || !patch) {
450
+ return originalSetRequestHandler(...args)
362
451
  }
363
452
 
364
453
  // Register first so the MCP SDK's request/result validation stays inside
365
454
  // our analytics wrapper, matching handlers that existed before instrument().
366
- const result = originalSetRequestHandler(requestSchema, originalHandler)
455
+ const result = originalSetRequestHandler(...args)
367
456
  const registeredHandler = server._requestHandlers.get(handlerName)
368
457
  if (registeredHandler) {
369
458
  rememberOriginalRequestHandler(server, handlerName, registeredHandler)
@@ -478,7 +567,7 @@ function cacheToolAnalyticsParameterOwnership(
478
567
  // Merge pages and concurrent enumerations; repeated tool names overwrite stale schemas.
479
568
  for (const tool of tools) {
480
569
  if (tool?.name) {
481
- cache.set(tool.name, getAnalyticsParameterOwnership(tool.inputSchema))
570
+ cache.set(tool.name, getAnalyticsParameterOwnership(tool.inputSchema, tool.outputSchema))
482
571
  }
483
572
  }
484
573
  }
@@ -515,7 +604,6 @@ async function getTracedToolsList(
515
604
 
516
605
  if (data) {
517
606
  const missingToolName = resolveMissingCapabilityToolName(data.options)
518
- let injectedMissingCapabilityTool = false
519
607
  if (data.options.reportMissing) {
520
608
  const alreadyPresent = tools.some((tool) => tool?.name === missingToolName)
521
609
  if (alreadyPresent) {
@@ -523,13 +611,17 @@ async function getTracedToolsList(
523
611
  `Warning: Cannot inject missing-capability tool "${missingToolName}" because a real tool already uses that name. The real tool will not be intercepted.`
524
612
  )
525
613
  } else {
526
- tools.push(getReportMissingToolDescriptor(missingToolName))
527
- injectedMissingCapabilityTool = true
614
+ const virtualTool = getReportMissingToolDescriptor(missingToolName)
615
+ tools.push(virtualTool)
616
+ // Cached separately because the virtual tool is added after the listing
617
+ // was cached, and its calls need ownership like any other tool's.
618
+ cacheToolAnalyticsParameterOwnership(data.toolAnalyticsParameterOwnership, [virtualTool])
528
619
  }
529
620
  }
530
621
 
531
622
  if (data.options.enableConversationId) {
532
- tools = addConversationIdToTools(tools, missingToolName, injectedMissingCapabilityTool, data.logger)
623
+ tools = addConversationIdToTools(tools, data.logger)
624
+ tools = addInstructionsToOutputSchemas(tools, data.logger)
533
625
  }
534
626
  }
535
627
 
@@ -130,7 +130,14 @@ export async function handleIdentify(
130
130
  sessionId: string,
131
131
  request: MCPRequestLike,
132
132
  requestAttribution: SessionInfo,
133
- extra?: CompatibleRequestHandlerExtra
133
+ extra?: CompatibleRequestHandlerExtra,
134
+ /**
135
+ * True when this session id came from an agent-carried `conversation_id`
136
+ * rather than the transport. Such a session is brand new and was never
137
+ * announced at `initialize`, whatever `data.sessionSource` still says about
138
+ * the connection.
139
+ */
140
+ sessionFromConversation = false
134
141
  ): Promise<UserIdentity | undefined> {
135
142
  const identityBeforeRequest = data.identifiedSessions.get(sessionId)
136
143
  const sessionSourceBeforeIdentify = data.sessionSource
@@ -166,7 +173,8 @@ export async function handleIdentify(
166
173
  // announced); revisit with the stateless-by-default rework.
167
174
  const changed = previousIdentity !== undefined && !areIdentitiesEqual(previousIdentity, mergedIdentity)
168
175
  const firstSeen = previousIdentity === undefined
169
- const announcedAtInitialize = sessionSourceBeforeIdentify === 'token' && request.method !== 'initialize'
176
+ const announcedAtInitialize =
177
+ !sessionFromConversation && sessionSourceBeforeIdentify === 'token' && request.method !== 'initialize'
170
178
  const shouldPublish = changed || (firstSeen && !announcedAtInitialize)
171
179
 
172
180
  data.identifiedSessions.set(sessionId, mergedIdentity)
@@ -143,6 +143,24 @@ export function getObjectShape(schema: unknown): Record<string, unknown> | undef
143
143
  return rawShape
144
144
  }
145
145
 
146
+ /**
147
+ * Reads the method name a `setRequestHandler` registration is for.
148
+ *
149
+ * The two SDK majors disagree on what the first argument is: v1 passes the Zod
150
+ * schema for the request and carries the method as a literal on its shape, v2
151
+ * passes the method string itself. A registration we cannot name is one we
152
+ * cannot match against a patch — which is how a handler registered after
153
+ * `instrument()` on v2 ends up replacing our wrapper instead of being wrapped.
154
+ */
155
+ export function readRequestHandlerMethod(requestSchema: unknown): string | undefined {
156
+ if (typeof requestSchema === 'string') {
157
+ return requestSchema
158
+ }
159
+ const shape = getObjectShape(requestSchema)
160
+ const method = shape?.method ? getLiteralValue(shape.method) : undefined
161
+ return typeof method === 'string' ? method : undefined
162
+ }
163
+
146
164
  export function getLiteralValue(schema: unknown): unknown {
147
165
  if (!schema || typeof schema !== 'object') {
148
166
  return
@@ -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
+ }