@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.
- 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/instrument-lowlevel.d.ts.map +1 -1
- package/dist/extensions/instrument-lowlevel.js +6 -5
- package/dist/extensions/instrument-lowlevel.mjs +7 -6
- package/dist/extensions/instrumentation.d.ts +22 -0
- package/dist/extensions/instrumentation.d.ts.map +1 -1
- package/dist/extensions/instrumentation.js +48 -23
- package/dist/extensions/instrumentation.mjs +46 -24
- 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/mcp-sdk-compat.d.ts +10 -0
- package/dist/extensions/mcp-sdk-compat.d.ts.map +1 -1
- package/dist/extensions/mcp-sdk-compat.js +11 -2
- package/dist/extensions/mcp-sdk-compat.mjs +7 -1
- 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/instrument-lowlevel.ts +14 -9
- package/src/extensions/instrumentation.ts +130 -38
- package/src/extensions/internal.ts +10 -2
- package/src/extensions/mcp-sdk-compat.ts +18 -0
- 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
|
@@ -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 {
|
|
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 =
|
|
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(
|
|
@@ -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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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(
|
|
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
|
-
|
|
527
|
-
|
|
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,
|
|
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 =
|
|
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
|
+
}
|