@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.
- 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/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 +35 -16
- package/dist/extensions/instrumentation.mjs +35 -16
- package/dist/extensions/internal.d.ts +8 -1
- package/dist/extensions/internal.d.ts.map +1 -1
- package/dist/extensions/internal.js +2 -2
- package/dist/extensions/internal.mjs +2 -2
- 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 +32 -14
- package/dist/extensions/session.mjs +30 -15
- 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 +4 -4
- package/src/extensions/analytics-parameters.ts +15 -2
- package/src/extensions/conversation-id.ts +51 -29
- package/src/extensions/instrument-highlevel.ts +3 -1
- package/src/extensions/instrumentation.ts +77 -29
- package/src/extensions/internal.ts +10 -2
- package/src/extensions/output-instructions.ts +231 -0
- package/src/extensions/session.ts +127 -28
- package/src/types.ts +23 -1
- 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
|
|
94
|
-
* - agent
|
|
95
|
-
* -
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
|
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
|
-
|
|
273
|
+
const conversationId = conversation.conversationId
|
|
274
|
+
if (!conversationId) {
|
|
244
275
|
return result
|
|
245
276
|
}
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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,
|
|
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 =
|
|
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
|
+
}
|