@namzu/sdk 62.12.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.
Files changed (174) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/connector/index.d.ts +1 -1
  3. package/dist/connector/index.d.ts.map +1 -1
  4. package/dist/connector/index.js +1 -1
  5. package/dist/connector/index.js.map +1 -1
  6. package/dist/connector/mcp/client.d.ts +96 -0
  7. package/dist/connector/mcp/client.d.ts.map +1 -1
  8. package/dist/connector/mcp/client.js +488 -13
  9. package/dist/connector/mcp/client.js.map +1 -1
  10. package/dist/connector/mcp/index.d.ts +1 -0
  11. package/dist/connector/mcp/index.d.ts.map +1 -1
  12. package/dist/connector/mcp/index.js +1 -0
  13. package/dist/connector/mcp/index.js.map +1 -1
  14. package/dist/connector/mcp/tasks.d.ts +40 -0
  15. package/dist/connector/mcp/tasks.d.ts.map +1 -0
  16. package/dist/connector/mcp/tasks.js +55 -0
  17. package/dist/connector/mcp/tasks.js.map +1 -0
  18. package/dist/constants/mcp/index.d.ts +1 -1
  19. package/dist/constants/mcp/index.d.ts.map +1 -1
  20. package/dist/constants/mcp/index.js +5 -0
  21. package/dist/constants/mcp/index.js.map +1 -1
  22. package/dist/eval/bootstrap.d.ts +122 -0
  23. package/dist/eval/bootstrap.d.ts.map +1 -0
  24. package/dist/eval/bootstrap.js +227 -0
  25. package/dist/eval/bootstrap.js.map +1 -0
  26. package/dist/eval/compare.d.ts +109 -0
  27. package/dist/eval/compare.d.ts.map +1 -0
  28. package/dist/eval/compare.js +324 -0
  29. package/dist/eval/compare.js.map +1 -0
  30. package/dist/eval/disclosure.d.ts +70 -0
  31. package/dist/eval/disclosure.d.ts.map +1 -0
  32. package/dist/eval/disclosure.js +64 -0
  33. package/dist/eval/disclosure.js.map +1 -0
  34. package/dist/eval/experiment.d.ts +23 -0
  35. package/dist/eval/experiment.d.ts.map +1 -1
  36. package/dist/eval/experiment.js +87 -15
  37. package/dist/eval/experiment.js.map +1 -1
  38. package/dist/eval/from-turn.d.ts.map +1 -1
  39. package/dist/eval/from-turn.js +5 -0
  40. package/dist/eval/from-turn.js.map +1 -1
  41. package/dist/eval/index.d.ts +13 -2
  42. package/dist/eval/index.d.ts.map +1 -1
  43. package/dist/eval/index.js +6 -1
  44. package/dist/eval/index.js.map +1 -1
  45. package/dist/eval/report-stats.d.ts +77 -0
  46. package/dist/eval/report-stats.d.ts.map +1 -0
  47. package/dist/eval/report-stats.js +184 -0
  48. package/dist/eval/report-stats.js.map +1 -0
  49. package/dist/eval/run-options.d.ts +30 -0
  50. package/dist/eval/run-options.d.ts.map +1 -0
  51. package/dist/eval/run-options.js +20 -0
  52. package/dist/eval/run-options.js.map +1 -0
  53. package/dist/eval/run-summary.d.ts +36 -0
  54. package/dist/eval/run-summary.d.ts.map +1 -0
  55. package/dist/eval/run-summary.js +57 -0
  56. package/dist/eval/run-summary.js.map +1 -0
  57. package/dist/eval/scorers.d.ts +32 -0
  58. package/dist/eval/scorers.d.ts.map +1 -1
  59. package/dist/eval/scorers.js +77 -0
  60. package/dist/eval/scorers.js.map +1 -1
  61. package/dist/eval/types.d.ts +74 -0
  62. package/dist/eval/types.d.ts.map +1 -1
  63. package/dist/manager/session/turn-recorder.d.ts +3 -0
  64. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  65. package/dist/manager/session/turn-recorder.js +18 -2
  66. package/dist/manager/session/turn-recorder.js.map +1 -1
  67. package/dist/public-runtime.d.ts +5 -2
  68. package/dist/public-runtime.d.ts.map +1 -1
  69. package/dist/public-runtime.js +7 -2
  70. package/dist/public-runtime.js.map +1 -1
  71. package/dist/public-types.d.ts +2 -0
  72. package/dist/public-types.d.ts.map +1 -1
  73. package/dist/registry/command/kernel-commands.d.ts +3 -0
  74. package/dist/registry/command/kernel-commands.d.ts.map +1 -1
  75. package/dist/registry/command/kernel-commands.js +76 -5
  76. package/dist/registry/command/kernel-commands.js.map +1 -1
  77. package/dist/runtime/query/index.d.ts.map +1 -1
  78. package/dist/runtime/query/index.js +15 -0
  79. package/dist/runtime/query/index.js.map +1 -1
  80. package/dist/runtime/query/iteration/index.d.ts +18 -1
  81. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  82. package/dist/runtime/query/iteration/index.js +80 -1
  83. package/dist/runtime/query/iteration/index.js.map +1 -1
  84. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  85. package/dist/runtime/query/iteration/phases/tool-review.js +18 -1
  86. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  87. package/dist/runtime/query/turn/answer-claims.d.ts +52 -0
  88. package/dist/runtime/query/turn/answer-claims.d.ts.map +1 -0
  89. package/dist/runtime/query/turn/answer-claims.js +348 -0
  90. package/dist/runtime/query/turn/answer-claims.js.map +1 -0
  91. package/dist/runtime/query/turn/outcome-ledger.d.ts +7 -0
  92. package/dist/runtime/query/turn/outcome-ledger.d.ts.map +1 -1
  93. package/dist/runtime/query/turn/outcome-ledger.js +13 -0
  94. package/dist/runtime/query/turn/outcome-ledger.js.map +1 -1
  95. package/dist/store/goal/index.d.ts +3 -1
  96. package/dist/store/goal/index.d.ts.map +1 -1
  97. package/dist/store/goal/index.js +6 -0
  98. package/dist/store/goal/index.js.map +1 -1
  99. package/dist/telemetry/runtime-accessors.d.ts +14 -0
  100. package/dist/telemetry/runtime-accessors.d.ts.map +1 -1
  101. package/dist/telemetry/runtime-accessors.js +32 -0
  102. package/dist/telemetry/runtime-accessors.js.map +1 -1
  103. package/dist/tools/goal/confirmation.d.ts +154 -0
  104. package/dist/tools/goal/confirmation.d.ts.map +1 -0
  105. package/dist/tools/goal/confirmation.js +290 -0
  106. package/dist/tools/goal/confirmation.js.map +1 -0
  107. package/dist/tools/goal/index.d.ts +4 -1
  108. package/dist/tools/goal/index.d.ts.map +1 -1
  109. package/dist/tools/goal/index.js +27 -2
  110. package/dist/tools/goal/index.js.map +1 -1
  111. package/dist/tools/hand-back/index.d.ts +48 -0
  112. package/dist/tools/hand-back/index.d.ts.map +1 -0
  113. package/dist/tools/hand-back/index.js +129 -0
  114. package/dist/tools/hand-back/index.js.map +1 -0
  115. package/dist/tools/schedules/schedule-tool.d.ts.map +1 -1
  116. package/dist/tools/schedules/schedule-tool.js +15 -1
  117. package/dist/tools/schedules/schedule-tool.js.map +1 -1
  118. package/dist/tools/schedules/types.d.ts +10 -0
  119. package/dist/tools/schedules/types.d.ts.map +1 -1
  120. package/dist/types/connector/mcp.d.ts +18 -0
  121. package/dist/types/connector/mcp.d.ts.map +1 -1
  122. package/dist/types/goal/index.d.ts +12 -0
  123. package/dist/types/goal/index.d.ts.map +1 -1
  124. package/dist/types/session/config.d.ts +8 -0
  125. package/dist/types/session/config.d.ts.map +1 -1
  126. package/dist/types/session/hand-back.d.ts +29 -0
  127. package/dist/types/session/hand-back.d.ts.map +1 -0
  128. package/dist/types/session/hand-back.js +11 -0
  129. package/dist/types/session/hand-back.js.map +1 -0
  130. package/dist/types/session/outcome.d.ts +37 -0
  131. package/dist/types/session/outcome.d.ts.map +1 -1
  132. package/dist/types/session/outcome.js.map +1 -1
  133. package/dist/types/session/turn.d.ts +19 -1
  134. package/dist/types/session/turn.d.ts.map +1 -1
  135. package/dist/types/session/turn.js.map +1 -1
  136. package/package.json +1 -1
  137. package/src/connector/index.ts +1 -0
  138. package/src/connector/mcp/client.ts +531 -14
  139. package/src/connector/mcp/index.ts +1 -0
  140. package/src/connector/mcp/tasks.ts +81 -0
  141. package/src/constants/mcp/index.ts +6 -1
  142. package/src/eval/bootstrap.ts +314 -0
  143. package/src/eval/compare.ts +473 -0
  144. package/src/eval/disclosure.ts +136 -0
  145. package/src/eval/experiment.ts +127 -15
  146. package/src/eval/from-turn.ts +5 -0
  147. package/src/eval/index.ts +51 -0
  148. package/src/eval/report-stats.ts +257 -0
  149. package/src/eval/run-options.ts +49 -0
  150. package/src/eval/run-summary.ts +104 -0
  151. package/src/eval/scorers.ts +83 -0
  152. package/src/eval/types.ts +76 -0
  153. package/src/manager/session/turn-recorder.ts +23 -2
  154. package/src/public-runtime.ts +53 -0
  155. package/src/public-types.ts +9 -0
  156. package/src/registry/command/kernel-commands.ts +94 -6
  157. package/src/runtime/query/index.ts +18 -0
  158. package/src/runtime/query/iteration/index.ts +85 -1
  159. package/src/runtime/query/iteration/phases/tool-review.ts +24 -1
  160. package/src/runtime/query/turn/answer-claims.ts +413 -0
  161. package/src/runtime/query/turn/outcome-ledger.ts +12 -0
  162. package/src/store/goal/index.ts +11 -0
  163. package/src/telemetry/runtime-accessors.ts +29 -0
  164. package/src/tools/goal/confirmation.ts +375 -0
  165. package/src/tools/goal/index.ts +37 -1
  166. package/src/tools/hand-back/index.ts +150 -0
  167. package/src/tools/schedules/schedule-tool.ts +19 -1
  168. package/src/tools/schedules/types.ts +10 -0
  169. package/src/types/connector/mcp.ts +18 -0
  170. package/src/types/goal/index.ts +13 -0
  171. package/src/types/session/config.ts +8 -0
  172. package/src/types/session/hand-back.ts +31 -0
  173. package/src/types/session/outcome.ts +42 -0
  174. 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(capabilities: MCPServerCapabilities | undefined): ListChangeFilter {
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) || (entry !== true && entry !== false)) return undefined
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.config.capabilities ?? {},
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
- return this.admitToolHeaderAnnotations(listed)
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.requestWithSessionRecovery('resources/read', { uri }, options)) as {
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.requestWithSessionRecovery(
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.config.capabilities ?? {},
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.config.capabilities ?? {},
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.config.capabilities ?? {},
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 as keyof ListChangeFilter]) handler(method)
2370
+ if (subscription.acknowledged?.[kind]) {
2371
+ this.invalidateCacheFor(method)
2372
+ handler(method)
2373
+ }
1857
2374
  }
1858
2375
  }
1859
2376