@posthog/mcp 0.10.7 → 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.
Files changed (41) 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/instrumentation.d.ts.map +1 -1
  13. package/dist/extensions/instrumentation.js +35 -16
  14. package/dist/extensions/instrumentation.mjs +35 -16
  15. package/dist/extensions/internal.d.ts +8 -1
  16. package/dist/extensions/internal.d.ts.map +1 -1
  17. package/dist/extensions/internal.js +2 -2
  18. package/dist/extensions/internal.mjs +2 -2
  19. package/dist/extensions/output-instructions.d.ts +70 -0
  20. package/dist/extensions/output-instructions.d.ts.map +1 -0
  21. package/dist/extensions/output-instructions.js +131 -0
  22. package/dist/extensions/output-instructions.mjs +82 -0
  23. package/dist/extensions/session.d.ts +26 -5
  24. package/dist/extensions/session.d.ts.map +1 -1
  25. package/dist/extensions/session.js +32 -14
  26. package/dist/extensions/session.mjs +30 -15
  27. package/dist/types.d.ts +23 -1
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/dist/version.mjs +1 -1
  32. package/package.json +4 -4
  33. package/src/extensions/analytics-parameters.ts +15 -2
  34. package/src/extensions/conversation-id.ts +51 -29
  35. package/src/extensions/instrument-highlevel.ts +3 -1
  36. package/src/extensions/instrumentation.ts +77 -29
  37. package/src/extensions/internal.ts +10 -2
  38. package/src/extensions/output-instructions.ts +231 -0
  39. package/src/extensions/session.ts +127 -28
  40. package/src/types.ts +23 -1
  41. package/src/version.ts +1 -1
@@ -11,7 +11,6 @@ import {
11
11
  } from './analytics-parameters'
12
12
  import { DEFAULT_CONVERSATION_ID_DESCRIPTION } from './constants'
13
13
  import { log, type LoggerFn } from './logger'
14
- import { GET_MORE_TOOLS_NAME } from './tools'
15
14
 
16
15
  export const CONVERSATION_ID_PARAM_NAME = 'conversation_id'
17
16
 
@@ -70,60 +69,83 @@ export function addConversationIdToTool<TTool extends ConversationIdInjectableTo
70
69
  return modifiedTool
71
70
  }
72
71
 
72
+ /**
73
+ * Injects `conversation_id` across a tool listing, including the virtual
74
+ * `get_more_tools` tool. Its calls publish `$mcp_missing_capability`, and a
75
+ * capability gap is only meaningful next to the work that hit it — so it belongs
76
+ * in the same session as the surrounding tool calls.
77
+ */
73
78
  export function addConversationIdToTools<TTool extends ConversationIdInjectableTool>(
74
79
  tools: TTool[],
75
- missingCapabilityToolName: string = GET_MORE_TOOLS_NAME,
76
- skipMissingCapabilityTool = true,
77
80
  logger: LoggerFn = log
78
81
  ): TTool[] {
79
- return tools.map((tool) => {
80
- if (skipMissingCapabilityTool && tool.name === missingCapabilityToolName) {
81
- return tool
82
- }
83
- return addConversationIdToTool(tool, logger)
84
- })
82
+ return tools.map((tool) => addConversationIdToTool(tool, logger))
85
83
  }
86
84
 
85
+ /**
86
+ * The shape of every id we mint: a uuidv7. Used to tell an echo of our own handle
87
+ * from a value the agent made up.
88
+ */
89
+ const MINTED_CONVERSATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
90
+
87
91
  export type ConversationIdResolution =
88
92
  | { minted: false; conversationId: string | undefined }
89
93
  | { minted: true; conversationId: string }
90
94
 
91
95
  /**
92
96
  * Decides which conversation_id to use for a tool call:
93
- * - disabled or get_more_tools → none
94
- * - agent supplied a value → use it
95
- * - agent omitted → mint a UUID
97
+ * - disabled → none
98
+ * - agent echoed a handle we could have minted → use it
99
+ * - anything else → mint a fresh one
100
+ *
101
+ * The shape check matters because this value becomes `$session_id`, and the
102
+ * derivation is deterministic so that two pods agree — which also means two
103
+ * *callers* sending the same string land in the same session. The strings agents
104
+ * invent are not random (`conv-1`, `1`, `session`), so trusting them verbatim
105
+ * would silently merge unrelated conversations, potentially across users.
106
+ *
107
+ * A compliant agent echoes the uuidv7 we minted and is unaffected. Anything else
108
+ * is treated exactly as if the handle were absent: mint, and prompt back. Shape
109
+ * rather than a registry of issued ids, because a per-request server has no
110
+ * memory of what it minted.
111
+ *
112
+ * This narrows the problem, it does not prove provenance: a caller supplying a
113
+ * well-formed uuidv7 is trusted, so two that pick the *same* one still merge. That
114
+ * residue is far smaller than the case it replaces — an agent ignoring the
115
+ * parameter description reaches for `conv-1` or `1`, not a conforming uuidv7 — and
116
+ * closing it needs a signed handle, i.e. a secret shared by every pod. Worth doing
117
+ * if this ever anchors something security-bearing; `$session_id` is an analytics
118
+ * grouping key, so it does not today.
96
119
  */
97
- export function resolveConversationId(
98
- enabled: boolean,
99
- args: unknown,
100
- toolName: string | undefined,
101
- missingCapabilityToolName: string = GET_MORE_TOOLS_NAME
102
- ): ConversationIdResolution {
103
- if (!enabled || toolName === missingCapabilityToolName) {
120
+ export function resolveConversationId(enabled: boolean, args: unknown): ConversationIdResolution {
121
+ if (!enabled) {
104
122
  return { minted: false, conversationId: undefined }
105
123
  }
106
124
  const supplied = extractConversationId(args)
107
- if (supplied) {
108
- return { minted: false, conversationId: supplied }
125
+ if (supplied && MINTED_CONVERSATION_ID.test(supplied)) {
126
+ // Lowercased because the shape test is case-insensitive but the hash behind
127
+ // `$session_id` is not. Some hosts normalise uuids to uppercase, and an
128
+ // uppercased echo of our own handle would otherwise clear the gate and then
129
+ // land in a different session than the call that minted it.
130
+ return { minted: false, conversationId: supplied.toLowerCase() }
109
131
  }
110
132
  return { minted: true, conversationId: uuidv7() }
111
133
  }
112
134
 
113
135
  /**
114
- * Predicate matching the same eligibility checks injectConversationIdPromptBack uses,
115
- * exposed so callers can pre-decide whether the prompt-back will actually land
116
- * (and clear event.conversationId if not, to avoid orphan ids in analytics).
136
+ * Whether the prompt-back can ride this result's `content`. The only requirement
137
+ * is an array to append to.
138
+ *
139
+ * Errored results included on purpose. A tool that fails on the first call of a
140
+ * conversation is exactly when the agent needs the session handle: without it the
141
+ * retry starts a fresh conversation, so the failure and its fix land in different
142
+ * sessions.
117
143
  */
118
144
  export function canInjectConversationIdPromptBack(result: unknown): boolean {
119
145
  if (!(result && typeof result === 'object')) {
120
146
  return false
121
147
  }
122
- const resultObj = result as { content?: unknown; isError?: unknown }
123
- if (resultObj.isError === true) {
124
- return false
125
- }
126
- return Array.isArray(resultObj.content)
148
+ return Array.isArray((result as { content?: unknown }).content)
127
149
  }
128
150
 
129
151
  export function extractConversationId(args: unknown): string | undefined {
@@ -262,7 +262,9 @@ async function handleToolCallRequest(
262
262
  request,
263
263
  extra,
264
264
  execute: (downstreamRequest) => originalCallToolHandler(downstreamRequest as MCPRequest, extra),
265
- parameterOwnership: registeredTool ? getAnalyticsParameterOwnership(registeredTool.inputSchema) : undefined,
265
+ parameterOwnership: registeredTool
266
+ ? getAnalyticsParameterOwnership(registeredTool.inputSchema, registeredTool.outputSchema)
267
+ : undefined,
266
268
  takeCapturedError: () => {
267
269
  const captured = extra?.__mcp_analytics_error
268
270
  if (extra) {
@@ -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'
@@ -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(
@@ -478,7 +523,7 @@ function cacheToolAnalyticsParameterOwnership(
478
523
  // Merge pages and concurrent enumerations; repeated tool names overwrite stale schemas.
479
524
  for (const tool of tools) {
480
525
  if (tool?.name) {
481
- cache.set(tool.name, getAnalyticsParameterOwnership(tool.inputSchema))
526
+ cache.set(tool.name, getAnalyticsParameterOwnership(tool.inputSchema, tool.outputSchema))
482
527
  }
483
528
  }
484
529
  }
@@ -515,7 +560,6 @@ async function getTracedToolsList(
515
560
 
516
561
  if (data) {
517
562
  const missingToolName = resolveMissingCapabilityToolName(data.options)
518
- let injectedMissingCapabilityTool = false
519
563
  if (data.options.reportMissing) {
520
564
  const alreadyPresent = tools.some((tool) => tool?.name === missingToolName)
521
565
  if (alreadyPresent) {
@@ -523,13 +567,17 @@ async function getTracedToolsList(
523
567
  `Warning: Cannot inject missing-capability tool "${missingToolName}" because a real tool already uses that name. The real tool will not be intercepted.`
524
568
  )
525
569
  } else {
526
- tools.push(getReportMissingToolDescriptor(missingToolName))
527
- injectedMissingCapabilityTool = true
570
+ const virtualTool = getReportMissingToolDescriptor(missingToolName)
571
+ tools.push(virtualTool)
572
+ // Cached separately because the virtual tool is added after the listing
573
+ // was cached, and its calls need ownership like any other tool's.
574
+ cacheToolAnalyticsParameterOwnership(data.toolAnalyticsParameterOwnership, [virtualTool])
528
575
  }
529
576
  }
530
577
 
531
578
  if (data.options.enableConversationId) {
532
- tools = addConversationIdToTools(tools, missingToolName, injectedMissingCapabilityTool, data.logger)
579
+ tools = addConversationIdToTools(tools, data.logger)
580
+ tools = addInstructionsToOutputSchemas(tools, data.logger)
533
581
  }
534
582
  }
535
583
 
@@ -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)
@@ -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
+ }