@namzu/sdk 62.11.0 → 62.13.0
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/CHANGELOG.md +29 -0
- package/dist/connector/index.d.ts +1 -1
- package/dist/connector/index.d.ts.map +1 -1
- package/dist/connector/index.js +1 -1
- package/dist/connector/index.js.map +1 -1
- package/dist/connector/mcp/client.d.ts +96 -0
- package/dist/connector/mcp/client.d.ts.map +1 -1
- package/dist/connector/mcp/client.js +488 -13
- package/dist/connector/mcp/client.js.map +1 -1
- package/dist/connector/mcp/index.d.ts +1 -0
- package/dist/connector/mcp/index.d.ts.map +1 -1
- package/dist/connector/mcp/index.js +1 -0
- package/dist/connector/mcp/index.js.map +1 -1
- package/dist/connector/mcp/tasks.d.ts +40 -0
- package/dist/connector/mcp/tasks.d.ts.map +1 -0
- package/dist/connector/mcp/tasks.js +55 -0
- package/dist/connector/mcp/tasks.js.map +1 -0
- package/dist/constants/mcp/index.d.ts +1 -1
- package/dist/constants/mcp/index.d.ts.map +1 -1
- package/dist/constants/mcp/index.js +5 -0
- package/dist/constants/mcp/index.js.map +1 -1
- package/dist/eval/bootstrap.d.ts +122 -0
- package/dist/eval/bootstrap.d.ts.map +1 -0
- package/dist/eval/bootstrap.js +227 -0
- package/dist/eval/bootstrap.js.map +1 -0
- package/dist/eval/compare.d.ts +109 -0
- package/dist/eval/compare.d.ts.map +1 -0
- package/dist/eval/compare.js +324 -0
- package/dist/eval/compare.js.map +1 -0
- package/dist/eval/disclosure.d.ts +70 -0
- package/dist/eval/disclosure.d.ts.map +1 -0
- package/dist/eval/disclosure.js +64 -0
- package/dist/eval/disclosure.js.map +1 -0
- package/dist/eval/experiment.d.ts +23 -0
- package/dist/eval/experiment.d.ts.map +1 -1
- package/dist/eval/experiment.js +87 -15
- package/dist/eval/experiment.js.map +1 -1
- package/dist/eval/from-turn.d.ts.map +1 -1
- package/dist/eval/from-turn.js +5 -0
- package/dist/eval/from-turn.js.map +1 -1
- package/dist/eval/index.d.ts +13 -2
- package/dist/eval/index.d.ts.map +1 -1
- package/dist/eval/index.js +6 -1
- package/dist/eval/index.js.map +1 -1
- package/dist/eval/report-stats.d.ts +77 -0
- package/dist/eval/report-stats.d.ts.map +1 -0
- package/dist/eval/report-stats.js +184 -0
- package/dist/eval/report-stats.js.map +1 -0
- package/dist/eval/run-options.d.ts +30 -0
- package/dist/eval/run-options.d.ts.map +1 -0
- package/dist/eval/run-options.js +20 -0
- package/dist/eval/run-options.js.map +1 -0
- package/dist/eval/run-summary.d.ts +36 -0
- package/dist/eval/run-summary.d.ts.map +1 -0
- package/dist/eval/run-summary.js +57 -0
- package/dist/eval/run-summary.js.map +1 -0
- package/dist/eval/scorers.d.ts +32 -0
- package/dist/eval/scorers.d.ts.map +1 -1
- package/dist/eval/scorers.js +77 -0
- package/dist/eval/scorers.js.map +1 -1
- package/dist/eval/types.d.ts +74 -0
- package/dist/eval/types.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.d.ts +3 -0
- package/dist/manager/session/turn-recorder.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.js +20 -5
- package/dist/manager/session/turn-recorder.js.map +1 -1
- package/dist/public-runtime.d.ts +5 -2
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +7 -2
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +2 -0
- package/dist/public-types.d.ts.map +1 -1
- package/dist/registry/command/kernel-commands.d.ts +3 -0
- package/dist/registry/command/kernel-commands.d.ts.map +1 -1
- package/dist/registry/command/kernel-commands.js +76 -5
- package/dist/registry/command/kernel-commands.js.map +1 -1
- package/dist/runtime/query/abandon-turn.d.ts.map +1 -1
- package/dist/runtime/query/abandon-turn.js +2 -2
- package/dist/runtime/query/abandon-turn.js.map +1 -1
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +15 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts +18 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +80 -1
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +18 -1
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/prelude-lease.d.ts.map +1 -1
- package/dist/runtime/query/prelude-lease.js +2 -2
- package/dist/runtime/query/prelude-lease.js.map +1 -1
- package/dist/runtime/query/turn/answer-claims.d.ts +52 -0
- package/dist/runtime/query/turn/answer-claims.d.ts.map +1 -0
- package/dist/runtime/query/turn/answer-claims.js +348 -0
- package/dist/runtime/query/turn/answer-claims.js.map +1 -0
- package/dist/runtime/query/turn/outcome-ledger.d.ts +7 -0
- package/dist/runtime/query/turn/outcome-ledger.d.ts.map +1 -1
- package/dist/runtime/query/turn/outcome-ledger.js +13 -0
- package/dist/runtime/query/turn/outcome-ledger.js.map +1 -1
- package/dist/store/goal/index.d.ts +3 -1
- package/dist/store/goal/index.d.ts.map +1 -1
- package/dist/store/goal/index.js +6 -0
- package/dist/store/goal/index.js.map +1 -1
- package/dist/store/session-log/index.d.ts +1 -0
- package/dist/store/session-log/index.d.ts.map +1 -1
- package/dist/store/session-log/index.js +1 -0
- package/dist/store/session-log/index.js.map +1 -1
- package/dist/store/session-log/turn-holder.d.ts +26 -0
- package/dist/store/session-log/turn-holder.d.ts.map +1 -0
- package/dist/store/session-log/turn-holder.js +79 -0
- package/dist/store/session-log/turn-holder.js.map +1 -0
- package/dist/telemetry/runtime-accessors.d.ts +14 -0
- package/dist/telemetry/runtime-accessors.d.ts.map +1 -1
- package/dist/telemetry/runtime-accessors.js +32 -0
- package/dist/telemetry/runtime-accessors.js.map +1 -1
- package/dist/tools/goal/confirmation.d.ts +154 -0
- package/dist/tools/goal/confirmation.d.ts.map +1 -0
- package/dist/tools/goal/confirmation.js +290 -0
- package/dist/tools/goal/confirmation.js.map +1 -0
- package/dist/tools/goal/index.d.ts +4 -1
- package/dist/tools/goal/index.d.ts.map +1 -1
- package/dist/tools/goal/index.js +27 -2
- package/dist/tools/goal/index.js.map +1 -1
- package/dist/tools/hand-back/index.d.ts +48 -0
- package/dist/tools/hand-back/index.d.ts.map +1 -0
- package/dist/tools/hand-back/index.js +129 -0
- package/dist/tools/hand-back/index.js.map +1 -0
- package/dist/tools/schedules/schedule-tool.d.ts.map +1 -1
- package/dist/tools/schedules/schedule-tool.js +15 -1
- package/dist/tools/schedules/schedule-tool.js.map +1 -1
- package/dist/tools/schedules/types.d.ts +10 -0
- package/dist/tools/schedules/types.d.ts.map +1 -1
- package/dist/types/connector/mcp.d.ts +18 -0
- package/dist/types/connector/mcp.d.ts.map +1 -1
- package/dist/types/goal/index.d.ts +12 -0
- package/dist/types/goal/index.d.ts.map +1 -1
- package/dist/types/session/config.d.ts +8 -0
- package/dist/types/session/config.d.ts.map +1 -1
- package/dist/types/session/hand-back.d.ts +29 -0
- package/dist/types/session/hand-back.d.ts.map +1 -0
- package/dist/types/session/hand-back.js +11 -0
- package/dist/types/session/hand-back.js.map +1 -0
- package/dist/types/session/outcome.d.ts +37 -0
- package/dist/types/session/outcome.d.ts.map +1 -1
- package/dist/types/session/outcome.js.map +1 -1
- package/dist/types/session/turn.d.ts +19 -1
- package/dist/types/session/turn.d.ts.map +1 -1
- package/dist/types/session/turn.js.map +1 -1
- package/package.json +1 -1
- package/src/connector/index.ts +1 -0
- package/src/connector/mcp/client.ts +531 -14
- package/src/connector/mcp/index.ts +1 -0
- package/src/connector/mcp/tasks.ts +81 -0
- package/src/constants/mcp/index.ts +6 -1
- package/src/eval/bootstrap.ts +314 -0
- package/src/eval/compare.ts +473 -0
- package/src/eval/disclosure.ts +136 -0
- package/src/eval/experiment.ts +127 -15
- package/src/eval/from-turn.ts +5 -0
- package/src/eval/index.ts +51 -0
- package/src/eval/report-stats.ts +257 -0
- package/src/eval/run-options.ts +49 -0
- package/src/eval/run-summary.ts +104 -0
- package/src/eval/scorers.ts +83 -0
- package/src/eval/types.ts +76 -0
- package/src/manager/session/turn-recorder.ts +25 -4
- package/src/public-runtime.ts +53 -0
- package/src/public-types.ts +9 -0
- package/src/registry/command/kernel-commands.ts +94 -6
- package/src/runtime/query/abandon-turn.ts +2 -1
- package/src/runtime/query/index.ts +18 -0
- package/src/runtime/query/iteration/index.ts +85 -1
- package/src/runtime/query/iteration/phases/tool-review.ts +24 -1
- package/src/runtime/query/prelude-lease.ts +7 -2
- package/src/runtime/query/turn/answer-claims.ts +413 -0
- package/src/runtime/query/turn/outcome-ledger.ts +12 -0
- package/src/store/goal/index.ts +11 -0
- package/src/store/session-log/index.ts +8 -0
- package/src/store/session-log/turn-holder.ts +95 -0
- package/src/telemetry/runtime-accessors.ts +29 -0
- package/src/tools/goal/confirmation.ts +375 -0
- package/src/tools/goal/index.ts +37 -1
- package/src/tools/hand-back/index.ts +150 -0
- package/src/tools/schedules/schedule-tool.ts +19 -1
- package/src/tools/schedules/types.ts +10 -0
- package/src/types/connector/mcp.ts +18 -0
- package/src/types/goal/index.ts +13 -0
- package/src/types/session/config.ts +8 -0
- package/src/types/session/hand-back.ts +31 -0
- package/src/types/session/outcome.ts +42 -0
- package/src/types/session/turn.ts +19 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
|
|
3
|
+
import { getTraceContextHeaders } from '../../telemetry/runtime-accessors.js'
|
|
3
4
|
import type {
|
|
5
|
+
MCPClientCapabilities,
|
|
4
6
|
MCPClientConfig,
|
|
5
7
|
MCPClientState,
|
|
6
8
|
MCPConnectionStatus,
|
|
@@ -58,6 +60,13 @@ import {
|
|
|
58
60
|
import { HttpSseTransport } from './http-sse.js'
|
|
59
61
|
import { StdioTransport } from './stdio.js'
|
|
60
62
|
import { MCPSubscriptionResponseError, StreamableHttpTransport } from './streamable-http.js'
|
|
63
|
+
import {
|
|
64
|
+
type MCPTaskState,
|
|
65
|
+
MCP_TASKS_EXTENSION,
|
|
66
|
+
decodeTaskState,
|
|
67
|
+
isCreateTaskResult,
|
|
68
|
+
taskPollDelayMs,
|
|
69
|
+
} from './tasks.js'
|
|
61
70
|
import { type McpParamHeaderBinding, validateMcpHeaderAnnotations } from './x-mcp-header.js'
|
|
62
71
|
|
|
63
72
|
import {
|
|
@@ -83,6 +92,10 @@ const MAX_LIST_PAGES = 100
|
|
|
83
92
|
const CANCEL_NOTIFICATION_TIMEOUT_MS = 1_000
|
|
84
93
|
const MAX_PROGRESS_MESSAGE_BYTES = 512
|
|
85
94
|
const ANSI_CSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g')
|
|
95
|
+
/** A bound on remembered pages and reads; the oldest goes first. */
|
|
96
|
+
const MAX_CACHE_ENTRIES = 256
|
|
97
|
+
/** The longest a result is kept whatever `ttlMs` says: the hint is the server's, but a server with no change notifications must not pin an entry for the life of the connection. */
|
|
98
|
+
const MAX_CACHE_TTL_MS = 60 * 60 * 1_000
|
|
86
99
|
const SUBSCRIPTION_ID_META_KEY = 'io.modelcontextprotocol/subscriptionId'
|
|
87
100
|
const SUBSCRIPTION_RETRY_MAX_MS = 30_000
|
|
88
101
|
/** Auth may refresh; timeouts, conflicts and rate limits can clear without reconnecting. */
|
|
@@ -92,6 +105,35 @@ type ListChangeFilter = {
|
|
|
92
105
|
toolsListChanged?: true
|
|
93
106
|
promptsListChanged?: true
|
|
94
107
|
resourcesListChanged?: true
|
|
108
|
+
/** The resources whose `notifications/resources/updated` this stream asks for. */
|
|
109
|
+
resourceSubscriptions?: string[]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The three boolean kinds, which are the ones a list-changed notification maps to. */
|
|
113
|
+
type ListChangeKind = 'toolsListChanged' | 'promptsListChanged' | 'resourcesListChanged'
|
|
114
|
+
|
|
115
|
+
interface CachedResult {
|
|
116
|
+
readonly value: unknown
|
|
117
|
+
readonly expiresAt: number
|
|
118
|
+
/** Who the result was fetched for; a `private` result is served only to the same one. */
|
|
119
|
+
readonly scope: 'public' | { readonly authorization: string }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Methods whose results carry `ttlMs` and `cacheScope`. */
|
|
123
|
+
const CACHEABLE_METHODS = new Set([
|
|
124
|
+
'tools/list',
|
|
125
|
+
'prompts/list',
|
|
126
|
+
'resources/list',
|
|
127
|
+
'resources/templates/list',
|
|
128
|
+
'resources/read',
|
|
129
|
+
])
|
|
130
|
+
|
|
131
|
+
/** The cached methods a change notification makes stale. */
|
|
132
|
+
const INVALIDATED_BY: Readonly<Record<string, readonly string[]>> = {
|
|
133
|
+
'notifications/tools/list_changed': ['tools/list'],
|
|
134
|
+
'notifications/prompts/list_changed': ['prompts/list'],
|
|
135
|
+
'notifications/resources/list_changed': ['resources/list', 'resources/templates/list'],
|
|
136
|
+
'notifications/resources/updated': ['resources/read'],
|
|
95
137
|
}
|
|
96
138
|
|
|
97
139
|
interface ModernSubscription {
|
|
@@ -108,8 +150,14 @@ const SUBSCRIPTION_NOTIFICATIONS = {
|
|
|
108
150
|
'notifications/resources/list_changed': 'resourcesListChanged',
|
|
109
151
|
} as const
|
|
110
152
|
|
|
111
|
-
function subscriptionFilter(
|
|
153
|
+
function subscriptionFilter(
|
|
154
|
+
capabilities: MCPServerCapabilities | undefined,
|
|
155
|
+
resources: ReadonlySet<string> = new Set(),
|
|
156
|
+
): ListChangeFilter {
|
|
112
157
|
return {
|
|
158
|
+
...(capabilities?.resources?.subscribe === true && resources.size > 0
|
|
159
|
+
? { resourceSubscriptions: [...resources] }
|
|
160
|
+
: {}),
|
|
113
161
|
...(capabilities?.tools?.listChanged === true ? { toolsListChanged: true as const } : {}),
|
|
114
162
|
...(capabilities?.prompts?.listChanged === true ? { promptsListChanged: true as const } : {}),
|
|
115
163
|
...(capabilities?.resources?.listChanged === true
|
|
@@ -126,7 +174,21 @@ function acknowledgedFilter(
|
|
|
126
174
|
const record = value as Record<string, unknown>
|
|
127
175
|
const acknowledged: ListChangeFilter = {}
|
|
128
176
|
for (const [key, entry] of Object.entries(record)) {
|
|
129
|
-
if (!Object.hasOwn(requested, key)
|
|
177
|
+
if (!Object.hasOwn(requested, key)) return undefined
|
|
178
|
+
if (key === 'resourceSubscriptions') {
|
|
179
|
+
// The honored subset of the URIs asked for; anything else is a
|
|
180
|
+
// server claiming a subscription nobody opened.
|
|
181
|
+
const asked = requested.resourceSubscriptions ?? []
|
|
182
|
+
if (
|
|
183
|
+
!Array.isArray(entry) ||
|
|
184
|
+
entry.some((uri) => typeof uri !== 'string' || !asked.includes(uri))
|
|
185
|
+
) {
|
|
186
|
+
return undefined
|
|
187
|
+
}
|
|
188
|
+
acknowledged.resourceSubscriptions = [...(entry as string[])]
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if (entry !== true && entry !== false) return undefined
|
|
130
192
|
if (entry === true) (acknowledged as Record<string, true>)[key] = true
|
|
131
193
|
}
|
|
132
194
|
return acknowledged
|
|
@@ -157,6 +219,28 @@ function isPermanentSubscriptionFailure(error: unknown, requestId: number): bool
|
|
|
157
219
|
)
|
|
158
220
|
}
|
|
159
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Tools in a fixed order, by name (code points, not locale), a tie keeping
|
|
224
|
+
* the order the server sent.
|
|
225
|
+
*
|
|
226
|
+
* The tool list is part of the prompt a model is given, and a provider's
|
|
227
|
+
* prompt cache only holds while the prompt is byte for byte the same. The
|
|
228
|
+
* 2026-07-28 revision asks servers to list tools deterministically for that
|
|
229
|
+
* reason (a SHOULD); an older server, or one that lists from a hash map, can
|
|
230
|
+
* answer in a different order each time. Sorting here makes the order a
|
|
231
|
+
* property of this client, whatever the server did.
|
|
232
|
+
*/
|
|
233
|
+
function sortedByName(tools: MCPToolDefinition[]): MCPToolDefinition[] {
|
|
234
|
+
return tools
|
|
235
|
+
.map((tool, index) => ({ tool, index }))
|
|
236
|
+
.sort((a, b) => {
|
|
237
|
+
const x = typeof a.tool?.name === 'string' ? a.tool.name : ''
|
|
238
|
+
const y = typeof b.tool?.name === 'string' ? b.tool.name : ''
|
|
239
|
+
return x < y ? -1 : x > y ? 1 : a.index - b.index
|
|
240
|
+
})
|
|
241
|
+
.map(({ tool }) => tool)
|
|
242
|
+
}
|
|
243
|
+
|
|
160
244
|
const NAMZU_CLIENT_INFO = { name: 'namzu-sdk', version: VERSION }
|
|
161
245
|
|
|
162
246
|
/** Keep server-authored status text safe and small before it reaches a host UI. */
|
|
@@ -253,6 +337,16 @@ export class MCPClient {
|
|
|
253
337
|
private modernSubscription?: ModernSubscription
|
|
254
338
|
private subscriptionRestartTimer?: ReturnType<typeof setTimeout>
|
|
255
339
|
private subscriptionFailures = 0
|
|
340
|
+
/** Pending coalesced reopen of the listen stream after the resource set changed. */
|
|
341
|
+
private subscriptionFilterTimer?: ReturnType<typeof setTimeout>
|
|
342
|
+
/** Task waits in progress, so a disconnect can end them. */
|
|
343
|
+
private taskWaits = new Set<AbortController>()
|
|
344
|
+
/** Bumped when a notification or reconnection makes cached results stale; a fetch that started earlier is not stored. */
|
|
345
|
+
private cacheEpoch = 0
|
|
346
|
+
/** Results the server said may be reused, by method and parameters. See {@link cachedRequest}. */
|
|
347
|
+
private resultCache = new Map<string, CachedResult>()
|
|
348
|
+
/** Resources the host asked to hear about, kept across reconnections. */
|
|
349
|
+
private subscribedResources = new Set<string>()
|
|
256
350
|
/** Best-effort protocol cancellations still belong to this connection generation. */
|
|
257
351
|
private cancellationControllers = new Set<AbortController>()
|
|
258
352
|
/** Elicitations waiting on the person, by the server's request id. */
|
|
@@ -339,6 +433,7 @@ export class MCPClient {
|
|
|
339
433
|
}
|
|
340
434
|
|
|
341
435
|
this.stopModernSubscription(false)
|
|
436
|
+
this.clearResultCache()
|
|
342
437
|
this.status = 'connecting'
|
|
343
438
|
// Tool schemas belong to a listing, and a listing belongs to a
|
|
344
439
|
// connection. Carrying bindings across a reconnect would mirror the
|
|
@@ -472,6 +567,7 @@ export class MCPClient {
|
|
|
472
567
|
this.serverInstructions = result.instructions
|
|
473
568
|
|
|
474
569
|
await this.notify('notifications/initialized', {})
|
|
570
|
+
await this.resubscribeLegacyResources()
|
|
475
571
|
|
|
476
572
|
return result
|
|
477
573
|
}
|
|
@@ -689,7 +785,7 @@ export class MCPClient {
|
|
|
689
785
|
if (this.status !== 'connected' || this.era?.kind !== 'modern' || this.modernSubscription) {
|
|
690
786
|
return
|
|
691
787
|
}
|
|
692
|
-
const requested = subscriptionFilter(this.serverCapabilities)
|
|
788
|
+
const requested = subscriptionFilter(this.serverCapabilities, this.subscribedResources)
|
|
693
789
|
if (Object.keys(requested).length === 0) return
|
|
694
790
|
const id = this.nextRequestId++
|
|
695
791
|
const controller = new AbortController()
|
|
@@ -698,7 +794,7 @@ export class MCPClient {
|
|
|
698
794
|
method: 'subscriptions/listen',
|
|
699
795
|
params: { notifications: requested },
|
|
700
796
|
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
701
|
-
capabilities: this.
|
|
797
|
+
capabilities: this.clientCapabilities(),
|
|
702
798
|
})
|
|
703
799
|
const subscription: ModernSubscription = {
|
|
704
800
|
id,
|
|
@@ -813,6 +909,8 @@ export class MCPClient {
|
|
|
813
909
|
}
|
|
814
910
|
|
|
815
911
|
private stopModernSubscription(cancelPeer: boolean): void {
|
|
912
|
+
if (this.subscriptionFilterTimer) clearTimeout(this.subscriptionFilterTimer)
|
|
913
|
+
this.subscriptionFilterTimer = undefined
|
|
816
914
|
if (this.subscriptionRestartTimer) clearTimeout(this.subscriptionRestartTimer)
|
|
817
915
|
this.subscriptionRestartTimer = undefined
|
|
818
916
|
const subscription = this.modernSubscription
|
|
@@ -828,8 +926,10 @@ export class MCPClient {
|
|
|
828
926
|
|
|
829
927
|
async disconnect(): Promise<void> {
|
|
830
928
|
const reason = new Error('MCPClient disconnecting')
|
|
929
|
+
this.clearResultCache()
|
|
831
930
|
this.stopModernSubscription(true)
|
|
832
931
|
this.abortCancellations(reason)
|
|
932
|
+
for (const wait of [...this.taskWaits]) wait.abort(reason)
|
|
833
933
|
const alreadyDisconnected = this.status === 'disconnected'
|
|
834
934
|
|
|
835
935
|
this.rejectAllPending('MCPClient disconnecting')
|
|
@@ -888,7 +988,9 @@ export class MCPClient {
|
|
|
888
988
|
async listTools(options?: MCPRequestOptions): Promise<MCPToolDefinition[]> {
|
|
889
989
|
this.requireConnected()
|
|
890
990
|
const listed = await this.listAllPages<MCPToolDefinition>('tools/list', 'tools', options)
|
|
891
|
-
|
|
991
|
+
const admitted = this.admitToolHeaderAnnotations(listed)
|
|
992
|
+
// Only a 2026-07-28 connection is sorted: an older server's callers keep the order it sent.
|
|
993
|
+
return this.era?.kind === 'modern' ? sortedByName(admitted) : admitted
|
|
892
994
|
}
|
|
893
995
|
|
|
894
996
|
/**
|
|
@@ -917,6 +1019,7 @@ export class MCPClient {
|
|
|
917
1019
|
'namzu.connector.server': this.config.serverName,
|
|
918
1020
|
'namzu.mcp.tool': name,
|
|
919
1021
|
})
|
|
1022
|
+
this.clearResultCache('tools/list')
|
|
920
1023
|
await this.listTools(options)
|
|
921
1024
|
return (await this.callToolDecoded(name, args ?? {}, options)) as MCPToolResult
|
|
922
1025
|
}
|
|
@@ -1002,6 +1105,7 @@ export class MCPClient {
|
|
|
1002
1105
|
{ name, arguments: args },
|
|
1003
1106
|
options,
|
|
1004
1107
|
)
|
|
1108
|
+
if (this.declaresTasks() && isCreateTaskResult(raw)) return await this.awaitTask(raw, options)
|
|
1005
1109
|
const decoded = decodeResult(raw)
|
|
1006
1110
|
if (decoded.kind === 'complete') return decoded.result
|
|
1007
1111
|
|
|
@@ -1016,6 +1120,9 @@ export class MCPClient {
|
|
|
1016
1120
|
{ name, arguments: args, requestState: decoded.requestState },
|
|
1017
1121
|
options,
|
|
1018
1122
|
)
|
|
1123
|
+
if (this.declaresTasks() && isCreateTaskResult(retryRaw)) {
|
|
1124
|
+
return await this.awaitTask(retryRaw, options)
|
|
1125
|
+
}
|
|
1019
1126
|
const retried = decodeResult(retryRaw)
|
|
1020
1127
|
if (retried.kind === 'complete') return retried.result
|
|
1021
1128
|
this.log.warn('MCP tool call required input again after the one automatic retry', {
|
|
@@ -1033,6 +1140,202 @@ export class MCPClient {
|
|
|
1033
1140
|
throw new MCPInputRequiredError(decoded.inputRequests ?? [])
|
|
1034
1141
|
}
|
|
1035
1142
|
|
|
1143
|
+
/**
|
|
1144
|
+
* The capabilities a modern request declares: the host's own, plus the
|
|
1145
|
+
* tasks extension when this client will handle a task.
|
|
1146
|
+
*/
|
|
1147
|
+
private clientCapabilities(): MCPClientCapabilities {
|
|
1148
|
+
const base = this.config.capabilities ?? {}
|
|
1149
|
+
if (!this.declaresTasks()) return base
|
|
1150
|
+
return { ...base, extensions: { ...base.extensions, [MCP_TASKS_EXTENSION]: {} } }
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Does this connection take part in the tasks extension?
|
|
1155
|
+
*
|
|
1156
|
+
* Only on a 2026-07-28 connection whose server advertised the extension in
|
|
1157
|
+
* `server/discover`, and only unless the host turned it off. A server must
|
|
1158
|
+
* never return a task to a client that did not declare it, so declaring is
|
|
1159
|
+
* also what makes a task arrive.
|
|
1160
|
+
*/
|
|
1161
|
+
private declaresTasks(): boolean {
|
|
1162
|
+
return (
|
|
1163
|
+
this.config.tasks !== false &&
|
|
1164
|
+
this.era?.kind === 'modern' &&
|
|
1165
|
+
this.serverCapabilities?.extensions?.[MCP_TASKS_EXTENSION] !== undefined
|
|
1166
|
+
)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Wait for a task the server made out of a `tools/call`, and return what
|
|
1171
|
+
* the call would have returned.
|
|
1172
|
+
*
|
|
1173
|
+
* Polls `tasks/get` at the interval the server last suggested (bounded to
|
|
1174
|
+
* 100 ms to 30 s). The wait keeps the deadline rules of a blocking call: a
|
|
1175
|
+
* call that gave no `onProgress` is bounded by the request timeout in all;
|
|
1176
|
+
* one that did, on a client built with `resetTimeoutOnProgress`, has that
|
|
1177
|
+
* timeout renewed by every change of the task's status or message, up to the
|
|
1178
|
+
* total cap. A change is also reported to `onProgress`, as a progress
|
|
1179
|
+
* notification would be: a count that rises by one per change, with the
|
|
1180
|
+
* task's `statusMessage` as the message.
|
|
1181
|
+
*
|
|
1182
|
+
* When the caller's signal fires or a deadline passes, the task is cancelled
|
|
1183
|
+
* with `tasks/cancel` (never `notifications/cancelled`, which the extension
|
|
1184
|
+
* forbids for tasks) before the call rejects. A task that asks for input
|
|
1185
|
+
* is cancelled too: this client has no one to ask on a modern connection,
|
|
1186
|
+
* and the call ends with the same typed error as any other unmet request.
|
|
1187
|
+
*/
|
|
1188
|
+
private async awaitTask(raw: unknown, options?: MCPRequestOptions): Promise<unknown> {
|
|
1189
|
+
const created = decodeTaskState(raw)
|
|
1190
|
+
if (!created) {
|
|
1191
|
+
throw new Error(
|
|
1192
|
+
`MCP server "${this.config.serverName}" answered with a task that has no usable taskId or status`,
|
|
1193
|
+
)
|
|
1194
|
+
}
|
|
1195
|
+
const taskId = created.taskId
|
|
1196
|
+
const wait = new AbortController()
|
|
1197
|
+
this.taskWaits.add(wait)
|
|
1198
|
+
const extendable = this.resetTimeoutOnProgress && options?.onProgress !== undefined
|
|
1199
|
+
const started = Date.now()
|
|
1200
|
+
const totalDeadline = started + (extendable ? this.maxTotalTimeoutMs : this.requestTimeoutMs)
|
|
1201
|
+
let lastChange = started
|
|
1202
|
+
let reports = 0
|
|
1203
|
+
let seen: string | undefined
|
|
1204
|
+
let state: MCPTaskState = created
|
|
1205
|
+
const pollOptions: MCPRequestOptions = {
|
|
1206
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
1207
|
+
...(options?.headers ? { headers: options.headers } : {}),
|
|
1208
|
+
...(options?.bearerToken !== undefined ? { bearerToken: options.bearerToken } : {}),
|
|
1209
|
+
}
|
|
1210
|
+
const note = (): void => {
|
|
1211
|
+
const fingerprint = `${state.status}\u0000${state.statusMessage ?? ''}\u0000${state.lastUpdatedAt ?? ''}`
|
|
1212
|
+
if (fingerprint === seen) return
|
|
1213
|
+
seen = fingerprint
|
|
1214
|
+
lastChange = Date.now()
|
|
1215
|
+
const message = progressMessage(state.statusMessage)
|
|
1216
|
+
options?.onProgress?.({ progress: ++reports, ...(message ? { message } : {}) })
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
for (;;) {
|
|
1220
|
+
note()
|
|
1221
|
+
switch (state.status) {
|
|
1222
|
+
case 'completed': {
|
|
1223
|
+
if (state.result === undefined) {
|
|
1224
|
+
throw new Error(
|
|
1225
|
+
`MCP task "${taskId}" on "${this.config.serverName}" completed without a result`,
|
|
1226
|
+
)
|
|
1227
|
+
}
|
|
1228
|
+
const decoded = decodeResult(state.result)
|
|
1229
|
+
if (decoded.kind !== 'complete') {
|
|
1230
|
+
throw new MCPInputRequiredError(decoded.inputRequests ?? [])
|
|
1231
|
+
}
|
|
1232
|
+
return decoded.result
|
|
1233
|
+
}
|
|
1234
|
+
case 'failed': {
|
|
1235
|
+
const message =
|
|
1236
|
+
typeof state.error?.message === 'string' ? state.error.message : 'MCP task failed'
|
|
1237
|
+
const code = state.error?.code
|
|
1238
|
+
if (typeof code !== 'number' || !Number.isInteger(code)) {
|
|
1239
|
+
// No usable code: say what the server said, not that its reply was malformed.
|
|
1240
|
+
throw new Error(
|
|
1241
|
+
`MCP task "${taskId}" on "${this.config.serverName}" failed: ${message}`,
|
|
1242
|
+
)
|
|
1243
|
+
}
|
|
1244
|
+
throw protocolErrorFromReply({
|
|
1245
|
+
code,
|
|
1246
|
+
message,
|
|
1247
|
+
...(state.error?.data !== undefined ? { data: state.error.data } : {}),
|
|
1248
|
+
})
|
|
1249
|
+
}
|
|
1250
|
+
case 'cancelled':
|
|
1251
|
+
throw new Error(
|
|
1252
|
+
`MCP task "${taskId}" on "${this.config.serverName}" was cancelled by the server`,
|
|
1253
|
+
)
|
|
1254
|
+
case 'input_required':
|
|
1255
|
+
this.log.warn('MCP task requires input this client cannot supply', {
|
|
1256
|
+
'namzu.connector.server': this.config.serverName,
|
|
1257
|
+
})
|
|
1258
|
+
this.cancelTask(taskId, pollOptions)
|
|
1259
|
+
throw new MCPInputRequiredError(state.inputRequests ?? [])
|
|
1260
|
+
default:
|
|
1261
|
+
break
|
|
1262
|
+
}
|
|
1263
|
+
const now = Date.now()
|
|
1264
|
+
const idleDeadline = extendable ? lastChange + this.requestTimeoutMs : totalDeadline
|
|
1265
|
+
const deadline = Math.min(totalDeadline, idleDeadline)
|
|
1266
|
+
if (now >= deadline) {
|
|
1267
|
+
const error = new Error(
|
|
1268
|
+
`MCP task "${taskId}" on "${this.config.serverName}" timed out after ${now - started}ms`,
|
|
1269
|
+
)
|
|
1270
|
+
error.name = 'TimeoutError'
|
|
1271
|
+
this.log.warn('MCP task timed out', {
|
|
1272
|
+
'namzu.connector.server': this.config.serverName,
|
|
1273
|
+
'namzu.connector.timeout_ms': now - started,
|
|
1274
|
+
})
|
|
1275
|
+
throw error
|
|
1276
|
+
}
|
|
1277
|
+
await this.pause(
|
|
1278
|
+
Math.min(taskPollDelayMs(state.pollIntervalMs), deadline - now),
|
|
1279
|
+
options?.signal ? AbortSignal.any([options.signal, wait.signal]) : wait.signal,
|
|
1280
|
+
)
|
|
1281
|
+
const polled = decodeTaskState(await this.request('tasks/get', { taskId }, pollOptions))
|
|
1282
|
+
if (!polled || polled.taskId !== taskId) {
|
|
1283
|
+
throw new Error(
|
|
1284
|
+
`MCP server "${this.config.serverName}" answered tasks/get with something that is not task "${taskId}"`,
|
|
1285
|
+
)
|
|
1286
|
+
}
|
|
1287
|
+
state = polled
|
|
1288
|
+
}
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
// A wait that ended because this side stopped (the caller's signal or a
|
|
1291
|
+
// deadline) leaves the work running on the server unless told. A server's
|
|
1292
|
+
// own answer (failed, unknown task) has nothing left to cancel.
|
|
1293
|
+
const stoppedHere =
|
|
1294
|
+
options?.signal?.aborted === true || (err instanceof Error && err.name === 'TimeoutError')
|
|
1295
|
+
if (stoppedHere) this.cancelTask(taskId, pollOptions)
|
|
1296
|
+
throw err
|
|
1297
|
+
} finally {
|
|
1298
|
+
this.taskWaits.delete(wait)
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** Sleep, ending early with the signal's reason if it fires. */
|
|
1303
|
+
private pause(ms: number, signal: AbortSignal): Promise<void> {
|
|
1304
|
+
return new Promise<void>((resolve, reject) => {
|
|
1305
|
+
if (signal.aborted) {
|
|
1306
|
+
reject(signal.reason)
|
|
1307
|
+
return
|
|
1308
|
+
}
|
|
1309
|
+
const onAbort = (): void => {
|
|
1310
|
+
clearTimeout(timer)
|
|
1311
|
+
reject(signal.reason)
|
|
1312
|
+
}
|
|
1313
|
+
const timer = setTimeout(() => {
|
|
1314
|
+
signal.removeEventListener('abort', onAbort)
|
|
1315
|
+
resolve()
|
|
1316
|
+
}, ms)
|
|
1317
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
1318
|
+
})
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** `tasks/cancel`, best effort and never awaited: the caller is already leaving. */
|
|
1322
|
+
private cancelTask(taskId: string, authority: MCPRequestOptions): void {
|
|
1323
|
+
if (!this.isConnected()) return
|
|
1324
|
+
const { signal: _callerSignal, ...rest } = authority
|
|
1325
|
+
void this.request(
|
|
1326
|
+
'tasks/cancel',
|
|
1327
|
+
{ taskId },
|
|
1328
|
+
{
|
|
1329
|
+
...rest,
|
|
1330
|
+
signal: AbortSignal.timeout(CANCEL_NOTIFICATION_TIMEOUT_MS),
|
|
1331
|
+
},
|
|
1332
|
+
).catch((err: unknown) => {
|
|
1333
|
+
this.log.debug('MCP task cancellation was not acknowledged', {
|
|
1334
|
+
'exception.message': toErrorMessage(err),
|
|
1335
|
+
})
|
|
1336
|
+
})
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1036
1339
|
async listResources(options?: MCPRequestOptions): Promise<MCPResource[]> {
|
|
1037
1340
|
this.requireConnected()
|
|
1038
1341
|
return await this.listAllPages('resources/list', 'resources', options)
|
|
@@ -1040,7 +1343,7 @@ export class MCPClient {
|
|
|
1040
1343
|
|
|
1041
1344
|
async readResource(uri: string, options?: MCPRequestOptions): Promise<MCPContentBlock[]> {
|
|
1042
1345
|
this.requireConnected()
|
|
1043
|
-
const result = (await this.
|
|
1346
|
+
const result = (await this.cachedRequest('resources/read', { uri }, options)) as {
|
|
1044
1347
|
contents: MCPContentBlock[]
|
|
1045
1348
|
}
|
|
1046
1349
|
return result.contents
|
|
@@ -1093,11 +1396,190 @@ export class MCPClient {
|
|
|
1093
1396
|
}
|
|
1094
1397
|
}
|
|
1095
1398
|
|
|
1399
|
+
/**
|
|
1400
|
+
* Ask the server to tell this client when one resource changes.
|
|
1401
|
+
*
|
|
1402
|
+
* The update arrives through {@link onNotification} as
|
|
1403
|
+
* `notifications/resources/updated` with the resource's `uri`; it says the
|
|
1404
|
+
* resource changed, not what it now holds, so a host reads it again.
|
|
1405
|
+
*
|
|
1406
|
+
* On a 2026-07-28 connection the URI joins the filter of the one
|
|
1407
|
+
* `subscriptions/listen` stream, which is reopened with the new filter. On
|
|
1408
|
+
* an older connection it is `resources/subscribe`, sent again after a
|
|
1409
|
+
* reconnection because those servers keep the subscription in the session.
|
|
1410
|
+
* Resolves `false` and sends nothing when the server does not advertise
|
|
1411
|
+
* `resources.subscribe`.
|
|
1412
|
+
*/
|
|
1413
|
+
async subscribeResource(uri: string, options?: MCPRequestOptions): Promise<boolean> {
|
|
1414
|
+
this.requireConnected()
|
|
1415
|
+
if (this.serverCapabilities?.resources?.subscribe !== true) return false
|
|
1416
|
+
if (this.era?.kind === 'legacy') {
|
|
1417
|
+
await this.requestWithSessionRecovery('resources/subscribe', { uri }, options)
|
|
1418
|
+
this.subscribedResources.add(uri)
|
|
1419
|
+
return true
|
|
1420
|
+
}
|
|
1421
|
+
if (!this.subscribedResources.has(uri)) {
|
|
1422
|
+
this.subscribedResources.add(uri)
|
|
1423
|
+
this.reopenModernSubscription()
|
|
1424
|
+
}
|
|
1425
|
+
return true
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/** Stop hearing about one resource. The mirror of {@link subscribeResource}. */
|
|
1429
|
+
async unsubscribeResource(uri: string, options?: MCPRequestOptions): Promise<void> {
|
|
1430
|
+
this.requireConnected()
|
|
1431
|
+
if (!this.subscribedResources.delete(uri)) return
|
|
1432
|
+
if (this.era?.kind === 'legacy') {
|
|
1433
|
+
await this.requestWithSessionRecovery('resources/unsubscribe', { uri }, options)
|
|
1434
|
+
return
|
|
1435
|
+
}
|
|
1436
|
+
this.reopenModernSubscription()
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** The filter changed: close the stream and open one that asks for the new set. */
|
|
1440
|
+
private reopenModernSubscription(): void {
|
|
1441
|
+
if (this.subscriptionFilterTimer) return
|
|
1442
|
+
if (!this.modernSubscription) {
|
|
1443
|
+
// Nothing open to disturb: open now.
|
|
1444
|
+
if (this.subscriptionRestartTimer) clearTimeout(this.subscriptionRestartTimer)
|
|
1445
|
+
this.subscriptionRestartTimer = undefined
|
|
1446
|
+
this.startModernSubscription()
|
|
1447
|
+
return
|
|
1448
|
+
}
|
|
1449
|
+
// Coalesced: a host that subscribes and unsubscribes in a burst reopens the
|
|
1450
|
+
// stream once, and not at all when the set ends where it began, so there
|
|
1451
|
+
// is no gap in which a change can be missed.
|
|
1452
|
+
this.subscriptionFilterTimer = setTimeout(() => {
|
|
1453
|
+
this.subscriptionFilterTimer = undefined
|
|
1454
|
+
if (this.status !== 'connected' || this.era?.kind !== 'modern') return
|
|
1455
|
+
const wanted = subscriptionFilter(this.serverCapabilities, this.subscribedResources)
|
|
1456
|
+
const current = this.modernSubscription
|
|
1457
|
+
if (current && JSON.stringify(current.requested) === JSON.stringify(wanted)) return
|
|
1458
|
+
if (this.subscriptionRestartTimer) clearTimeout(this.subscriptionRestartTimer)
|
|
1459
|
+
this.subscriptionRestartTimer = undefined
|
|
1460
|
+
if (current) this.finishModernSubscription(current, 'filter changed', true, false)
|
|
1461
|
+
this.startModernSubscription()
|
|
1462
|
+
}, 0)
|
|
1463
|
+
this.subscriptionFilterTimer.unref?.()
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** A new legacy session holds no subscriptions; ask again for the ones the host wanted. */
|
|
1467
|
+
private async resubscribeLegacyResources(): Promise<void> {
|
|
1468
|
+
if (this.serverCapabilities?.resources?.subscribe !== true) return
|
|
1469
|
+
for (const uri of this.subscribedResources) {
|
|
1470
|
+
try {
|
|
1471
|
+
await this.request('resources/subscribe', { uri })
|
|
1472
|
+
} catch (err) {
|
|
1473
|
+
this.log.warn('MCP resource subscription was not restored after reconnecting', {
|
|
1474
|
+
'namzu.connector.server': this.config.serverName,
|
|
1475
|
+
'exception.message': toErrorMessage(err),
|
|
1476
|
+
})
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1096
1481
|
async listResourceTemplates(options?: MCPRequestOptions): Promise<MCPResourceTemplate[]> {
|
|
1097
1482
|
this.requireConnected()
|
|
1098
1483
|
return await this.listAllPages('resources/templates/list', 'resourceTemplates', options)
|
|
1099
1484
|
}
|
|
1100
1485
|
|
|
1486
|
+
/**
|
|
1487
|
+
* `request()`, answered from memory when the server said it may be.
|
|
1488
|
+
*
|
|
1489
|
+
* A 2026-07-28 server puts `ttlMs` (milliseconds the result may be reused)
|
|
1490
|
+
* and `cacheScope` on what `tools/list`, `prompts/list`, `resources/list`,
|
|
1491
|
+
* `resources/templates/list` and `resources/read` return. Before this the
|
|
1492
|
+
* client ignored both and asked on every call. Now a result is kept for
|
|
1493
|
+
* exactly `ttlMs` after it arrived and asked for again after that, never
|
|
1494
|
+
* longer. Nothing is kept when `ttlMs` is absent, zero or negative (the
|
|
1495
|
+
* spec reads all three as "stale now"), when the result is not
|
|
1496
|
+
* `resultType: "complete"`, or on a connection that is not 2026-07-28 —
|
|
1497
|
+
* an older server sends no hints, so its lists are fetched every time, as
|
|
1498
|
+
* they always were.
|
|
1499
|
+
*
|
|
1500
|
+
* The key is the method plus its parameters, so each page of a list
|
|
1501
|
+
* (its cursor) and each resource (its uri) is its own entry. A change
|
|
1502
|
+
* notification drops what it makes stale at once, and a `private` result
|
|
1503
|
+
* is served only to a call with the same `Authorization` as the one that
|
|
1504
|
+
* fetched it; a result with no `cacheScope` is treated as `private`.
|
|
1505
|
+
*/
|
|
1506
|
+
private async cachedRequest(
|
|
1507
|
+
method: string,
|
|
1508
|
+
params: Record<string, unknown>,
|
|
1509
|
+
options?: MCPRequestOptions,
|
|
1510
|
+
): Promise<unknown> {
|
|
1511
|
+
if (this.era?.kind !== 'modern' || !CACHEABLE_METHODS.has(method)) {
|
|
1512
|
+
return await this.requestWithSessionRecovery(method, params, options)
|
|
1513
|
+
}
|
|
1514
|
+
const key = `${method}\u0000${JSON.stringify(params)}`
|
|
1515
|
+
const authorization = this.authorizationOf(options)
|
|
1516
|
+
const hit = this.resultCache.get(key)
|
|
1517
|
+
if (hit) {
|
|
1518
|
+
const sameAudience = hit.scope === 'public' || hit.scope.authorization === authorization
|
|
1519
|
+
if (sameAudience && Date.now() < hit.expiresAt) return structuredClone(hit.value)
|
|
1520
|
+
this.resultCache.delete(key)
|
|
1521
|
+
}
|
|
1522
|
+
const epoch = this.cacheEpoch
|
|
1523
|
+
const result = await this.requestWithSessionRecovery(method, params, options)
|
|
1524
|
+
const record = result as Record<string, unknown> | null
|
|
1525
|
+
const ttlMs = record?.ttlMs
|
|
1526
|
+
if (
|
|
1527
|
+
typeof ttlMs === 'number' &&
|
|
1528
|
+
Number.isFinite(ttlMs) &&
|
|
1529
|
+
ttlMs > 0 &&
|
|
1530
|
+
epoch === this.cacheEpoch &&
|
|
1531
|
+
(record?.resultType === undefined || record.resultType === 'complete')
|
|
1532
|
+
) {
|
|
1533
|
+
if (this.resultCache.size >= MAX_CACHE_ENTRIES) {
|
|
1534
|
+
const oldest = this.resultCache.keys().next().value
|
|
1535
|
+
if (oldest !== undefined) this.resultCache.delete(oldest)
|
|
1536
|
+
}
|
|
1537
|
+
this.resultCache.set(key, {
|
|
1538
|
+
// A copy, so a caller who edits what it was given does not edit what is kept.
|
|
1539
|
+
value: structuredClone(result),
|
|
1540
|
+
expiresAt: Date.now() + Math.min(ttlMs, MAX_CACHE_TTL_MS),
|
|
1541
|
+
scope: record?.cacheScope === 'public' ? 'public' : { authorization },
|
|
1542
|
+
})
|
|
1543
|
+
}
|
|
1544
|
+
return result
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/** The credential a call carries, for telling one caller's private results from another's. */
|
|
1548
|
+
private authorizationOf(options: MCPRequestOptions | undefined): string {
|
|
1549
|
+
if (options?.bearerToken !== undefined) return `Bearer ${options.bearerToken}`
|
|
1550
|
+
const headers = options?.headers ?? {}
|
|
1551
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1552
|
+
if (name.toLowerCase() === 'authorization') return value
|
|
1553
|
+
}
|
|
1554
|
+
return ''
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/** Forget what a notification says is out of date. */
|
|
1558
|
+
private invalidateCacheFor(notification: string, params?: Record<string, unknown>): void {
|
|
1559
|
+
const methods = INVALIDATED_BY[notification]
|
|
1560
|
+
if (!methods) return
|
|
1561
|
+
this.cacheEpoch++
|
|
1562
|
+
const uri = notification === 'notifications/resources/updated' ? params?.uri : undefined
|
|
1563
|
+
for (const key of [...this.resultCache.keys()]) {
|
|
1564
|
+
const method = key.slice(0, key.indexOf('\u0000'))
|
|
1565
|
+
if (!methods.includes(method)) continue
|
|
1566
|
+
if (typeof uri === 'string' && !key.endsWith(`\u0000${JSON.stringify({ uri })}`)) continue
|
|
1567
|
+
this.resultCache.delete(key)
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/** Forget every cached method, for a connection that may be a different server instance. */
|
|
1572
|
+
private clearResultCache(method?: string): void {
|
|
1573
|
+
this.cacheEpoch++
|
|
1574
|
+
if (method === undefined) {
|
|
1575
|
+
this.resultCache.clear()
|
|
1576
|
+
return
|
|
1577
|
+
}
|
|
1578
|
+
for (const key of [...this.resultCache.keys()]) {
|
|
1579
|
+
if (key.startsWith(`${method}\u0000`)) this.resultCache.delete(key)
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1101
1583
|
/**
|
|
1102
1584
|
* Read a paged list to the end.
|
|
1103
1585
|
*
|
|
@@ -1124,7 +1606,7 @@ export class MCPClient {
|
|
|
1124
1606
|
let cursor: string | undefined
|
|
1125
1607
|
|
|
1126
1608
|
for (let page = 1; ; page++) {
|
|
1127
|
-
const result = (await this.
|
|
1609
|
+
const result = (await this.cachedRequest(
|
|
1128
1610
|
method,
|
|
1129
1611
|
cursor === undefined ? {} : { cursor },
|
|
1130
1612
|
options,
|
|
@@ -1247,14 +1729,20 @@ export class MCPClient {
|
|
|
1247
1729
|
const progressToken = method === 'tools/call' && options?.onProgress ? randomUUID() : undefined
|
|
1248
1730
|
// Only a call that can receive progress can have its deadline extended.
|
|
1249
1731
|
const extendable = this.resetTimeoutOnProgress && progressToken !== undefined
|
|
1732
|
+
// The 2026-07-28 convention for carrying a trace across a request: the
|
|
1733
|
+
// W3C keys in `_meta`. Only on that revision, and only when a span is
|
|
1734
|
+
// active; an older server is sent exactly what it always was.
|
|
1735
|
+
const trace = this.era?.kind === 'modern' ? getTraceContextHeaders() : {}
|
|
1736
|
+
const hasTrace = trace.traceparent !== undefined
|
|
1250
1737
|
const requestParams =
|
|
1251
|
-
progressToken === undefined
|
|
1738
|
+
progressToken === undefined && !hasTrace
|
|
1252
1739
|
? params
|
|
1253
1740
|
: {
|
|
1254
1741
|
...params,
|
|
1255
1742
|
_meta: {
|
|
1743
|
+
...trace,
|
|
1256
1744
|
...(params._meta as Record<string, unknown> | undefined),
|
|
1257
|
-
progressToken,
|
|
1745
|
+
...(progressToken === undefined ? {} : { progressToken }),
|
|
1258
1746
|
},
|
|
1259
1747
|
}
|
|
1260
1748
|
const envelope = buildEnvelope({
|
|
@@ -1262,7 +1750,7 @@ export class MCPClient {
|
|
|
1262
1750
|
method,
|
|
1263
1751
|
params: requestParams,
|
|
1264
1752
|
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
1265
|
-
capabilities: this.
|
|
1753
|
+
capabilities: this.clientCapabilities(),
|
|
1266
1754
|
...this.paramHeaderBindings(method, params),
|
|
1267
1755
|
})
|
|
1268
1756
|
const message: MCPJsonRpcMessage = {
|
|
@@ -1525,7 +2013,7 @@ export class MCPClient {
|
|
|
1525
2013
|
method: 'notifications/cancelled',
|
|
1526
2014
|
params: { requestId: id, reason },
|
|
1527
2015
|
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
1528
|
-
capabilities: this.
|
|
2016
|
+
capabilities: this.clientCapabilities(),
|
|
1529
2017
|
})
|
|
1530
2018
|
const controller = new AbortController()
|
|
1531
2019
|
this.cancellationControllers.add(controller)
|
|
@@ -1596,7 +2084,7 @@ export class MCPClient {
|
|
|
1596
2084
|
method,
|
|
1597
2085
|
params,
|
|
1598
2086
|
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
1599
|
-
capabilities: this.
|
|
2087
|
+
capabilities: this.clientCapabilities(),
|
|
1600
2088
|
})
|
|
1601
2089
|
const message: MCPJsonRpcMessage = {
|
|
1602
2090
|
jsonrpc: '2.0',
|
|
@@ -1631,6 +2119,7 @@ export class MCPClient {
|
|
|
1631
2119
|
}
|
|
1632
2120
|
|
|
1633
2121
|
if (message.method && message.id === undefined) {
|
|
2122
|
+
this.invalidateCacheFor(message.method, message.params)
|
|
1634
2123
|
for (const handler of this.notificationHandlers) {
|
|
1635
2124
|
handler(message.method, message.params)
|
|
1636
2125
|
}
|
|
@@ -1652,6 +2141,15 @@ export class MCPClient {
|
|
|
1652
2141
|
void this.answerElicitation(message.id, message.params)
|
|
1653
2142
|
return
|
|
1654
2143
|
}
|
|
2144
|
+
// `ping` is part of every revision before 2026-07-28 and must be answered
|
|
2145
|
+
// with an empty result; a server that gets an error back may decide the
|
|
2146
|
+
// connection is dead. The newest revision removed it.
|
|
2147
|
+
if (message.method === 'ping' && message.id !== undefined && this.era?.kind === 'legacy') {
|
|
2148
|
+
void this.transport.send({ jsonrpc: '2.0', id: message.id, result: {} }).catch((err) => {
|
|
2149
|
+
this.log.debug('Failed to send ping reply', { 'exception.message': toErrorMessage(err) })
|
|
2150
|
+
})
|
|
2151
|
+
return
|
|
2152
|
+
}
|
|
1655
2153
|
if (message.method && message.id !== undefined) {
|
|
1656
2154
|
this.log.warn('Declining unsupported server-initiated MCP request', {
|
|
1657
2155
|
'namzu.connector.server': this.config.serverName,
|
|
@@ -1792,7 +2290,7 @@ export class MCPClient {
|
|
|
1792
2290
|
const method = message.method
|
|
1793
2291
|
if (!method) return false
|
|
1794
2292
|
const isAck = method === 'notifications/subscriptions/acknowledged'
|
|
1795
|
-
const kind = Object.hasOwn(SUBSCRIPTION_NOTIFICATIONS, method)
|
|
2293
|
+
const kind: ListChangeKind | undefined = Object.hasOwn(SUBSCRIPTION_NOTIFICATIONS, method)
|
|
1796
2294
|
? SUBSCRIPTION_NOTIFICATIONS[method as keyof typeof SUBSCRIPTION_NOTIFICATIONS]
|
|
1797
2295
|
: undefined
|
|
1798
2296
|
const isSubscribedEvent =
|
|
@@ -1822,6 +2320,10 @@ export class MCPClient {
|
|
|
1822
2320
|
}
|
|
1823
2321
|
subscription.acknowledged = acknowledged
|
|
1824
2322
|
clearTimeout(subscription.ackTimer)
|
|
2323
|
+
// A change may have landed before the stream went live.
|
|
2324
|
+
for (const [notification, acknowledgedKind] of Object.entries(SUBSCRIPTION_NOTIFICATIONS)) {
|
|
2325
|
+
if (acknowledged[acknowledgedKind]) this.invalidateCacheFor(notification)
|
|
2326
|
+
}
|
|
1825
2327
|
for (const handler of [...this.notificationHandlers]) {
|
|
1826
2328
|
this.reconcileSubscription(subscription, handler)
|
|
1827
2329
|
}
|
|
@@ -1835,6 +2337,17 @@ export class MCPClient {
|
|
|
1835
2337
|
)
|
|
1836
2338
|
return true
|
|
1837
2339
|
}
|
|
2340
|
+
if (method === 'notifications/resources/updated') {
|
|
2341
|
+
const uri = message.params?.uri
|
|
2342
|
+
if (
|
|
2343
|
+
typeof uri === 'string' &&
|
|
2344
|
+
subscription.acknowledged.resourceSubscriptions?.includes(uri) === true
|
|
2345
|
+
) {
|
|
2346
|
+
this.invalidateCacheFor(method, message.params)
|
|
2347
|
+
for (const handler of [...this.notificationHandlers]) handler(method, message.params)
|
|
2348
|
+
return true
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
1838
2351
|
if (!kind || subscription.acknowledged[kind] !== true) {
|
|
1839
2352
|
this.log.warn('Ignoring unrequested MCP subscription notification', {
|
|
1840
2353
|
'namzu.connector.server': this.config.serverName,
|
|
@@ -1842,6 +2355,7 @@ export class MCPClient {
|
|
|
1842
2355
|
})
|
|
1843
2356
|
return true
|
|
1844
2357
|
}
|
|
2358
|
+
this.invalidateCacheFor(method, message.params)
|
|
1845
2359
|
for (const handler of [...this.notificationHandlers]) handler(method, message.params)
|
|
1846
2360
|
return true
|
|
1847
2361
|
}
|
|
@@ -1853,7 +2367,10 @@ export class MCPClient {
|
|
|
1853
2367
|
): void {
|
|
1854
2368
|
if (this.modernSubscription !== subscription) return
|
|
1855
2369
|
for (const [method, kind] of Object.entries(SUBSCRIPTION_NOTIFICATIONS)) {
|
|
1856
|
-
if (subscription.acknowledged?.[kind
|
|
2370
|
+
if (subscription.acknowledged?.[kind]) {
|
|
2371
|
+
this.invalidateCacheFor(method)
|
|
2372
|
+
handler(method)
|
|
2373
|
+
}
|
|
1857
2374
|
}
|
|
1858
2375
|
}
|
|
1859
2376
|
|