@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,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { getTraceContextHeaders } from '../../telemetry/runtime-accessors.js';
|
|
2
3
|
import { toErrorMessage } from '../../utils/error.js';
|
|
3
4
|
import { generateMCPClientId } from '../../utils/id.js';
|
|
4
5
|
import { SCOPE_ATTRIBUTE } from '../../utils/log/types.js';
|
|
@@ -10,6 +11,7 @@ import { MCPHttpStatusError, MCPInputRequiredError, isHeaderMismatchError, proto
|
|
|
10
11
|
import { HttpSseTransport } from './http-sse.js';
|
|
11
12
|
import { StdioTransport } from './stdio.js';
|
|
12
13
|
import { MCPSubscriptionResponseError, StreamableHttpTransport } from './streamable-http.js';
|
|
14
|
+
import { MCP_TASKS_EXTENSION, decodeTaskState, isCreateTaskResult, taskPollDelayMs, } from './tasks.js';
|
|
13
15
|
import { validateMcpHeaderAnnotations } from './x-mcp-header.js';
|
|
14
16
|
import { DEFAULT_MCP_ERA_PROBE_TIMEOUT_MS, DEFAULT_MCP_REQUEST_TIMEOUT_MS, DEFAULT_MCP_TOTAL_TIMEOUT_FACTOR, JSON_RPC_INVALID_PARAMS, JSON_RPC_METHOD_NOT_FOUND, MCP_DISCOVER_METHOD, MCP_LEGACY_VERSIONS, MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, MCP_SUPPORTED_PROTOCOL_VERSIONS, } from '../../constants/mcp/index.js';
|
|
15
17
|
import { NAMZU } from '../../constants/telemetry/index.js';
|
|
@@ -20,17 +22,39 @@ const MAX_LIST_PAGES = 100;
|
|
|
20
22
|
const CANCEL_NOTIFICATION_TIMEOUT_MS = 1_000;
|
|
21
23
|
const MAX_PROGRESS_MESSAGE_BYTES = 512;
|
|
22
24
|
const ANSI_CSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g');
|
|
25
|
+
/** A bound on remembered pages and reads; the oldest goes first. */
|
|
26
|
+
const MAX_CACHE_ENTRIES = 256;
|
|
27
|
+
/** 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. */
|
|
28
|
+
const MAX_CACHE_TTL_MS = 60 * 60 * 1_000;
|
|
23
29
|
const SUBSCRIPTION_ID_META_KEY = 'io.modelcontextprotocol/subscriptionId';
|
|
24
30
|
const SUBSCRIPTION_RETRY_MAX_MS = 30_000;
|
|
25
31
|
/** Auth may refresh; timeouts, conflicts and rate limits can clear without reconnecting. */
|
|
26
32
|
const RETRYABLE_SUBSCRIPTION_CLIENT_STATUSES = new Set([401, 403, 408, 409, 425, 429]);
|
|
33
|
+
/** Methods whose results carry `ttlMs` and `cacheScope`. */
|
|
34
|
+
const CACHEABLE_METHODS = new Set([
|
|
35
|
+
'tools/list',
|
|
36
|
+
'prompts/list',
|
|
37
|
+
'resources/list',
|
|
38
|
+
'resources/templates/list',
|
|
39
|
+
'resources/read',
|
|
40
|
+
]);
|
|
41
|
+
/** The cached methods a change notification makes stale. */
|
|
42
|
+
const INVALIDATED_BY = {
|
|
43
|
+
'notifications/tools/list_changed': ['tools/list'],
|
|
44
|
+
'notifications/prompts/list_changed': ['prompts/list'],
|
|
45
|
+
'notifications/resources/list_changed': ['resources/list', 'resources/templates/list'],
|
|
46
|
+
'notifications/resources/updated': ['resources/read'],
|
|
47
|
+
};
|
|
27
48
|
const SUBSCRIPTION_NOTIFICATIONS = {
|
|
28
49
|
'notifications/tools/list_changed': 'toolsListChanged',
|
|
29
50
|
'notifications/prompts/list_changed': 'promptsListChanged',
|
|
30
51
|
'notifications/resources/list_changed': 'resourcesListChanged',
|
|
31
52
|
};
|
|
32
|
-
function subscriptionFilter(capabilities) {
|
|
53
|
+
function subscriptionFilter(capabilities, resources = new Set()) {
|
|
33
54
|
return {
|
|
55
|
+
...(capabilities?.resources?.subscribe === true && resources.size > 0
|
|
56
|
+
? { resourceSubscriptions: [...resources] }
|
|
57
|
+
: {}),
|
|
34
58
|
...(capabilities?.tools?.listChanged === true ? { toolsListChanged: true } : {}),
|
|
35
59
|
...(capabilities?.prompts?.listChanged === true ? { promptsListChanged: true } : {}),
|
|
36
60
|
...(capabilities?.resources?.listChanged === true
|
|
@@ -44,7 +68,20 @@ function acknowledgedFilter(value, requested) {
|
|
|
44
68
|
const record = value;
|
|
45
69
|
const acknowledged = {};
|
|
46
70
|
for (const [key, entry] of Object.entries(record)) {
|
|
47
|
-
if (!Object.hasOwn(requested, key)
|
|
71
|
+
if (!Object.hasOwn(requested, key))
|
|
72
|
+
return undefined;
|
|
73
|
+
if (key === 'resourceSubscriptions') {
|
|
74
|
+
// The honored subset of the URIs asked for; anything else is a
|
|
75
|
+
// server claiming a subscription nobody opened.
|
|
76
|
+
const asked = requested.resourceSubscriptions ?? [];
|
|
77
|
+
if (!Array.isArray(entry) ||
|
|
78
|
+
entry.some((uri) => typeof uri !== 'string' || !asked.includes(uri))) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
acknowledged.resourceSubscriptions = [...entry];
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (entry !== true && entry !== false)
|
|
48
85
|
return undefined;
|
|
49
86
|
if (entry === true)
|
|
50
87
|
acknowledged[key] = true;
|
|
@@ -72,6 +109,27 @@ function isPermanentSubscriptionFailure(error, requestId) {
|
|
|
72
109
|
error.status < 500 &&
|
|
73
110
|
!RETRYABLE_SUBSCRIPTION_CLIENT_STATUSES.has(error.status));
|
|
74
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Tools in a fixed order, by name (code points, not locale), a tie keeping
|
|
114
|
+
* the order the server sent.
|
|
115
|
+
*
|
|
116
|
+
* The tool list is part of the prompt a model is given, and a provider's
|
|
117
|
+
* prompt cache only holds while the prompt is byte for byte the same. The
|
|
118
|
+
* 2026-07-28 revision asks servers to list tools deterministically for that
|
|
119
|
+
* reason (a SHOULD); an older server, or one that lists from a hash map, can
|
|
120
|
+
* answer in a different order each time. Sorting here makes the order a
|
|
121
|
+
* property of this client, whatever the server did.
|
|
122
|
+
*/
|
|
123
|
+
function sortedByName(tools) {
|
|
124
|
+
return tools
|
|
125
|
+
.map((tool, index) => ({ tool, index }))
|
|
126
|
+
.sort((a, b) => {
|
|
127
|
+
const x = typeof a.tool?.name === 'string' ? a.tool.name : '';
|
|
128
|
+
const y = typeof b.tool?.name === 'string' ? b.tool.name : '';
|
|
129
|
+
return x < y ? -1 : x > y ? 1 : a.index - b.index;
|
|
130
|
+
})
|
|
131
|
+
.map(({ tool }) => tool);
|
|
132
|
+
}
|
|
75
133
|
const NAMZU_CLIENT_INFO = { name: 'namzu-sdk', version: VERSION };
|
|
76
134
|
/** Keep server-authored status text safe and small before it reaches a host UI. */
|
|
77
135
|
function progressMessage(value) {
|
|
@@ -149,6 +207,16 @@ export class MCPClient {
|
|
|
149
207
|
modernSubscription;
|
|
150
208
|
subscriptionRestartTimer;
|
|
151
209
|
subscriptionFailures = 0;
|
|
210
|
+
/** Pending coalesced reopen of the listen stream after the resource set changed. */
|
|
211
|
+
subscriptionFilterTimer;
|
|
212
|
+
/** Task waits in progress, so a disconnect can end them. */
|
|
213
|
+
taskWaits = new Set();
|
|
214
|
+
/** Bumped when a notification or reconnection makes cached results stale; a fetch that started earlier is not stored. */
|
|
215
|
+
cacheEpoch = 0;
|
|
216
|
+
/** Results the server said may be reused, by method and parameters. See {@link cachedRequest}. */
|
|
217
|
+
resultCache = new Map();
|
|
218
|
+
/** Resources the host asked to hear about, kept across reconnections. */
|
|
219
|
+
subscribedResources = new Set();
|
|
152
220
|
/** Best-effort protocol cancellations still belong to this connection generation. */
|
|
153
221
|
cancellationControllers = new Set();
|
|
154
222
|
/** Elicitations waiting on the person, by the server's request id. */
|
|
@@ -217,6 +285,7 @@ export class MCPClient {
|
|
|
217
285
|
throw new Error(`MCPClient already connected to "${this.config.serverName}"`);
|
|
218
286
|
}
|
|
219
287
|
this.stopModernSubscription(false);
|
|
288
|
+
this.clearResultCache();
|
|
220
289
|
this.status = 'connecting';
|
|
221
290
|
// Tool schemas belong to a listing, and a listing belongs to a
|
|
222
291
|
// connection. Carrying bindings across a reconnect would mirror the
|
|
@@ -336,6 +405,7 @@ export class MCPClient {
|
|
|
336
405
|
this.serverCapabilities = result.capabilities;
|
|
337
406
|
this.serverInstructions = result.instructions;
|
|
338
407
|
await this.notify('notifications/initialized', {});
|
|
408
|
+
await this.resubscribeLegacyResources();
|
|
339
409
|
return result;
|
|
340
410
|
}
|
|
341
411
|
/**
|
|
@@ -534,7 +604,7 @@ export class MCPClient {
|
|
|
534
604
|
if (this.status !== 'connected' || this.era?.kind !== 'modern' || this.modernSubscription) {
|
|
535
605
|
return;
|
|
536
606
|
}
|
|
537
|
-
const requested = subscriptionFilter(this.serverCapabilities);
|
|
607
|
+
const requested = subscriptionFilter(this.serverCapabilities, this.subscribedResources);
|
|
538
608
|
if (Object.keys(requested).length === 0)
|
|
539
609
|
return;
|
|
540
610
|
const id = this.nextRequestId++;
|
|
@@ -544,7 +614,7 @@ export class MCPClient {
|
|
|
544
614
|
method: 'subscriptions/listen',
|
|
545
615
|
params: { notifications: requested },
|
|
546
616
|
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
547
|
-
capabilities: this.
|
|
617
|
+
capabilities: this.clientCapabilities(),
|
|
548
618
|
});
|
|
549
619
|
const subscription = {
|
|
550
620
|
id,
|
|
@@ -646,6 +716,9 @@ export class MCPClient {
|
|
|
646
716
|
this.subscriptionRestartTimer.unref?.();
|
|
647
717
|
}
|
|
648
718
|
stopModernSubscription(cancelPeer) {
|
|
719
|
+
if (this.subscriptionFilterTimer)
|
|
720
|
+
clearTimeout(this.subscriptionFilterTimer);
|
|
721
|
+
this.subscriptionFilterTimer = undefined;
|
|
649
722
|
if (this.subscriptionRestartTimer)
|
|
650
723
|
clearTimeout(this.subscriptionRestartTimer);
|
|
651
724
|
this.subscriptionRestartTimer = undefined;
|
|
@@ -662,8 +735,11 @@ export class MCPClient {
|
|
|
662
735
|
}
|
|
663
736
|
async disconnect() {
|
|
664
737
|
const reason = new Error('MCPClient disconnecting');
|
|
738
|
+
this.clearResultCache();
|
|
665
739
|
this.stopModernSubscription(true);
|
|
666
740
|
this.abortCancellations(reason);
|
|
741
|
+
for (const wait of [...this.taskWaits])
|
|
742
|
+
wait.abort(reason);
|
|
667
743
|
const alreadyDisconnected = this.status === 'disconnected';
|
|
668
744
|
this.rejectAllPending('MCPClient disconnecting');
|
|
669
745
|
await this.transport.close();
|
|
@@ -717,7 +793,9 @@ export class MCPClient {
|
|
|
717
793
|
async listTools(options) {
|
|
718
794
|
this.requireConnected();
|
|
719
795
|
const listed = await this.listAllPages('tools/list', 'tools', options);
|
|
720
|
-
|
|
796
|
+
const admitted = this.admitToolHeaderAnnotations(listed);
|
|
797
|
+
// Only a 2026-07-28 connection is sorted: an older server's callers keep the order it sent.
|
|
798
|
+
return this.era?.kind === 'modern' ? sortedByName(admitted) : admitted;
|
|
721
799
|
}
|
|
722
800
|
/**
|
|
723
801
|
* Call one tool, and recover once from a server that says our mirrored
|
|
@@ -743,6 +821,7 @@ export class MCPClient {
|
|
|
743
821
|
'namzu.connector.server': this.config.serverName,
|
|
744
822
|
'namzu.mcp.tool': name,
|
|
745
823
|
});
|
|
824
|
+
this.clearResultCache('tools/list');
|
|
746
825
|
await this.listTools(options);
|
|
747
826
|
return (await this.callToolDecoded(name, args ?? {}, options));
|
|
748
827
|
}
|
|
@@ -818,6 +897,8 @@ export class MCPClient {
|
|
|
818
897
|
*/
|
|
819
898
|
async callToolDecoded(name, args, options) {
|
|
820
899
|
const raw = await this.requestWithSessionRecovery('tools/call', { name, arguments: args }, options);
|
|
900
|
+
if (this.declaresTasks() && isCreateTaskResult(raw))
|
|
901
|
+
return await this.awaitTask(raw, options);
|
|
821
902
|
const decoded = decodeResult(raw);
|
|
822
903
|
if (decoded.kind === 'complete')
|
|
823
904
|
return decoded.result;
|
|
@@ -828,6 +909,9 @@ export class MCPClient {
|
|
|
828
909
|
'namzu.connector.tool': name,
|
|
829
910
|
});
|
|
830
911
|
const retryRaw = await this.requestWithSessionRecovery('tools/call', { name, arguments: args, requestState: decoded.requestState }, options);
|
|
912
|
+
if (this.declaresTasks() && isCreateTaskResult(retryRaw)) {
|
|
913
|
+
return await this.awaitTask(retryRaw, options);
|
|
914
|
+
}
|
|
831
915
|
const retried = decodeResult(retryRaw);
|
|
832
916
|
if (retried.kind === 'complete')
|
|
833
917
|
return retried.result;
|
|
@@ -844,13 +928,187 @@ export class MCPClient {
|
|
|
844
928
|
});
|
|
845
929
|
throw new MCPInputRequiredError(decoded.inputRequests ?? []);
|
|
846
930
|
}
|
|
931
|
+
/**
|
|
932
|
+
* The capabilities a modern request declares: the host's own, plus the
|
|
933
|
+
* tasks extension when this client will handle a task.
|
|
934
|
+
*/
|
|
935
|
+
clientCapabilities() {
|
|
936
|
+
const base = this.config.capabilities ?? {};
|
|
937
|
+
if (!this.declaresTasks())
|
|
938
|
+
return base;
|
|
939
|
+
return { ...base, extensions: { ...base.extensions, [MCP_TASKS_EXTENSION]: {} } };
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Does this connection take part in the tasks extension?
|
|
943
|
+
*
|
|
944
|
+
* Only on a 2026-07-28 connection whose server advertised the extension in
|
|
945
|
+
* `server/discover`, and only unless the host turned it off. A server must
|
|
946
|
+
* never return a task to a client that did not declare it, so declaring is
|
|
947
|
+
* also what makes a task arrive.
|
|
948
|
+
*/
|
|
949
|
+
declaresTasks() {
|
|
950
|
+
return (this.config.tasks !== false &&
|
|
951
|
+
this.era?.kind === 'modern' &&
|
|
952
|
+
this.serverCapabilities?.extensions?.[MCP_TASKS_EXTENSION] !== undefined);
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Wait for a task the server made out of a `tools/call`, and return what
|
|
956
|
+
* the call would have returned.
|
|
957
|
+
*
|
|
958
|
+
* Polls `tasks/get` at the interval the server last suggested (bounded to
|
|
959
|
+
* 100 ms to 30 s). The wait keeps the deadline rules of a blocking call: a
|
|
960
|
+
* call that gave no `onProgress` is bounded by the request timeout in all;
|
|
961
|
+
* one that did, on a client built with `resetTimeoutOnProgress`, has that
|
|
962
|
+
* timeout renewed by every change of the task's status or message, up to the
|
|
963
|
+
* total cap. A change is also reported to `onProgress`, as a progress
|
|
964
|
+
* notification would be: a count that rises by one per change, with the
|
|
965
|
+
* task's `statusMessage` as the message.
|
|
966
|
+
*
|
|
967
|
+
* When the caller's signal fires or a deadline passes, the task is cancelled
|
|
968
|
+
* with `tasks/cancel` (never `notifications/cancelled`, which the extension
|
|
969
|
+
* forbids for tasks) before the call rejects. A task that asks for input
|
|
970
|
+
* is cancelled too: this client has no one to ask on a modern connection,
|
|
971
|
+
* and the call ends with the same typed error as any other unmet request.
|
|
972
|
+
*/
|
|
973
|
+
async awaitTask(raw, options) {
|
|
974
|
+
const created = decodeTaskState(raw);
|
|
975
|
+
if (!created) {
|
|
976
|
+
throw new Error(`MCP server "${this.config.serverName}" answered with a task that has no usable taskId or status`);
|
|
977
|
+
}
|
|
978
|
+
const taskId = created.taskId;
|
|
979
|
+
const wait = new AbortController();
|
|
980
|
+
this.taskWaits.add(wait);
|
|
981
|
+
const extendable = this.resetTimeoutOnProgress && options?.onProgress !== undefined;
|
|
982
|
+
const started = Date.now();
|
|
983
|
+
const totalDeadline = started + (extendable ? this.maxTotalTimeoutMs : this.requestTimeoutMs);
|
|
984
|
+
let lastChange = started;
|
|
985
|
+
let reports = 0;
|
|
986
|
+
let seen;
|
|
987
|
+
let state = created;
|
|
988
|
+
const pollOptions = {
|
|
989
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
990
|
+
...(options?.headers ? { headers: options.headers } : {}),
|
|
991
|
+
...(options?.bearerToken !== undefined ? { bearerToken: options.bearerToken } : {}),
|
|
992
|
+
};
|
|
993
|
+
const note = () => {
|
|
994
|
+
const fingerprint = `${state.status}\u0000${state.statusMessage ?? ''}\u0000${state.lastUpdatedAt ?? ''}`;
|
|
995
|
+
if (fingerprint === seen)
|
|
996
|
+
return;
|
|
997
|
+
seen = fingerprint;
|
|
998
|
+
lastChange = Date.now();
|
|
999
|
+
const message = progressMessage(state.statusMessage);
|
|
1000
|
+
options?.onProgress?.({ progress: ++reports, ...(message ? { message } : {}) });
|
|
1001
|
+
};
|
|
1002
|
+
try {
|
|
1003
|
+
for (;;) {
|
|
1004
|
+
note();
|
|
1005
|
+
switch (state.status) {
|
|
1006
|
+
case 'completed': {
|
|
1007
|
+
if (state.result === undefined) {
|
|
1008
|
+
throw new Error(`MCP task "${taskId}" on "${this.config.serverName}" completed without a result`);
|
|
1009
|
+
}
|
|
1010
|
+
const decoded = decodeResult(state.result);
|
|
1011
|
+
if (decoded.kind !== 'complete') {
|
|
1012
|
+
throw new MCPInputRequiredError(decoded.inputRequests ?? []);
|
|
1013
|
+
}
|
|
1014
|
+
return decoded.result;
|
|
1015
|
+
}
|
|
1016
|
+
case 'failed': {
|
|
1017
|
+
const message = typeof state.error?.message === 'string' ? state.error.message : 'MCP task failed';
|
|
1018
|
+
const code = state.error?.code;
|
|
1019
|
+
if (typeof code !== 'number' || !Number.isInteger(code)) {
|
|
1020
|
+
// No usable code: say what the server said, not that its reply was malformed.
|
|
1021
|
+
throw new Error(`MCP task "${taskId}" on "${this.config.serverName}" failed: ${message}`);
|
|
1022
|
+
}
|
|
1023
|
+
throw protocolErrorFromReply({
|
|
1024
|
+
code,
|
|
1025
|
+
message,
|
|
1026
|
+
...(state.error?.data !== undefined ? { data: state.error.data } : {}),
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
case 'cancelled':
|
|
1030
|
+
throw new Error(`MCP task "${taskId}" on "${this.config.serverName}" was cancelled by the server`);
|
|
1031
|
+
case 'input_required':
|
|
1032
|
+
this.log.warn('MCP task requires input this client cannot supply', {
|
|
1033
|
+
'namzu.connector.server': this.config.serverName,
|
|
1034
|
+
});
|
|
1035
|
+
this.cancelTask(taskId, pollOptions);
|
|
1036
|
+
throw new MCPInputRequiredError(state.inputRequests ?? []);
|
|
1037
|
+
default:
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
1040
|
+
const now = Date.now();
|
|
1041
|
+
const idleDeadline = extendable ? lastChange + this.requestTimeoutMs : totalDeadline;
|
|
1042
|
+
const deadline = Math.min(totalDeadline, idleDeadline);
|
|
1043
|
+
if (now >= deadline) {
|
|
1044
|
+
const error = new Error(`MCP task "${taskId}" on "${this.config.serverName}" timed out after ${now - started}ms`);
|
|
1045
|
+
error.name = 'TimeoutError';
|
|
1046
|
+
this.log.warn('MCP task timed out', {
|
|
1047
|
+
'namzu.connector.server': this.config.serverName,
|
|
1048
|
+
'namzu.connector.timeout_ms': now - started,
|
|
1049
|
+
});
|
|
1050
|
+
throw error;
|
|
1051
|
+
}
|
|
1052
|
+
await this.pause(Math.min(taskPollDelayMs(state.pollIntervalMs), deadline - now), options?.signal ? AbortSignal.any([options.signal, wait.signal]) : wait.signal);
|
|
1053
|
+
const polled = decodeTaskState(await this.request('tasks/get', { taskId }, pollOptions));
|
|
1054
|
+
if (!polled || polled.taskId !== taskId) {
|
|
1055
|
+
throw new Error(`MCP server "${this.config.serverName}" answered tasks/get with something that is not task "${taskId}"`);
|
|
1056
|
+
}
|
|
1057
|
+
state = polled;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
catch (err) {
|
|
1061
|
+
// A wait that ended because this side stopped (the caller's signal or a
|
|
1062
|
+
// deadline) leaves the work running on the server unless told. A server's
|
|
1063
|
+
// own answer (failed, unknown task) has nothing left to cancel.
|
|
1064
|
+
const stoppedHere = options?.signal?.aborted === true || (err instanceof Error && err.name === 'TimeoutError');
|
|
1065
|
+
if (stoppedHere)
|
|
1066
|
+
this.cancelTask(taskId, pollOptions);
|
|
1067
|
+
throw err;
|
|
1068
|
+
}
|
|
1069
|
+
finally {
|
|
1070
|
+
this.taskWaits.delete(wait);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/** Sleep, ending early with the signal's reason if it fires. */
|
|
1074
|
+
pause(ms, signal) {
|
|
1075
|
+
return new Promise((resolve, reject) => {
|
|
1076
|
+
if (signal.aborted) {
|
|
1077
|
+
reject(signal.reason);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
const onAbort = () => {
|
|
1081
|
+
clearTimeout(timer);
|
|
1082
|
+
reject(signal.reason);
|
|
1083
|
+
};
|
|
1084
|
+
const timer = setTimeout(() => {
|
|
1085
|
+
signal.removeEventListener('abort', onAbort);
|
|
1086
|
+
resolve();
|
|
1087
|
+
}, ms);
|
|
1088
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
/** `tasks/cancel`, best effort and never awaited: the caller is already leaving. */
|
|
1092
|
+
cancelTask(taskId, authority) {
|
|
1093
|
+
if (!this.isConnected())
|
|
1094
|
+
return;
|
|
1095
|
+
const { signal: _callerSignal, ...rest } = authority;
|
|
1096
|
+
void this.request('tasks/cancel', { taskId }, {
|
|
1097
|
+
...rest,
|
|
1098
|
+
signal: AbortSignal.timeout(CANCEL_NOTIFICATION_TIMEOUT_MS),
|
|
1099
|
+
}).catch((err) => {
|
|
1100
|
+
this.log.debug('MCP task cancellation was not acknowledged', {
|
|
1101
|
+
'exception.message': toErrorMessage(err),
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
847
1105
|
async listResources(options) {
|
|
848
1106
|
this.requireConnected();
|
|
849
1107
|
return await this.listAllPages('resources/list', 'resources', options);
|
|
850
1108
|
}
|
|
851
1109
|
async readResource(uri, options) {
|
|
852
1110
|
this.requireConnected();
|
|
853
|
-
const result = (await this.
|
|
1111
|
+
const result = (await this.cachedRequest('resources/read', { uri }, options));
|
|
854
1112
|
return result.contents;
|
|
855
1113
|
}
|
|
856
1114
|
/**
|
|
@@ -890,10 +1148,193 @@ export class MCPClient {
|
|
|
890
1148
|
messages: result.messages ?? [],
|
|
891
1149
|
};
|
|
892
1150
|
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Ask the server to tell this client when one resource changes.
|
|
1153
|
+
*
|
|
1154
|
+
* The update arrives through {@link onNotification} as
|
|
1155
|
+
* `notifications/resources/updated` with the resource's `uri`; it says the
|
|
1156
|
+
* resource changed, not what it now holds, so a host reads it again.
|
|
1157
|
+
*
|
|
1158
|
+
* On a 2026-07-28 connection the URI joins the filter of the one
|
|
1159
|
+
* `subscriptions/listen` stream, which is reopened with the new filter. On
|
|
1160
|
+
* an older connection it is `resources/subscribe`, sent again after a
|
|
1161
|
+
* reconnection because those servers keep the subscription in the session.
|
|
1162
|
+
* Resolves `false` and sends nothing when the server does not advertise
|
|
1163
|
+
* `resources.subscribe`.
|
|
1164
|
+
*/
|
|
1165
|
+
async subscribeResource(uri, options) {
|
|
1166
|
+
this.requireConnected();
|
|
1167
|
+
if (this.serverCapabilities?.resources?.subscribe !== true)
|
|
1168
|
+
return false;
|
|
1169
|
+
if (this.era?.kind === 'legacy') {
|
|
1170
|
+
await this.requestWithSessionRecovery('resources/subscribe', { uri }, options);
|
|
1171
|
+
this.subscribedResources.add(uri);
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
if (!this.subscribedResources.has(uri)) {
|
|
1175
|
+
this.subscribedResources.add(uri);
|
|
1176
|
+
this.reopenModernSubscription();
|
|
1177
|
+
}
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1180
|
+
/** Stop hearing about one resource. The mirror of {@link subscribeResource}. */
|
|
1181
|
+
async unsubscribeResource(uri, options) {
|
|
1182
|
+
this.requireConnected();
|
|
1183
|
+
if (!this.subscribedResources.delete(uri))
|
|
1184
|
+
return;
|
|
1185
|
+
if (this.era?.kind === 'legacy') {
|
|
1186
|
+
await this.requestWithSessionRecovery('resources/unsubscribe', { uri }, options);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
this.reopenModernSubscription();
|
|
1190
|
+
}
|
|
1191
|
+
/** The filter changed: close the stream and open one that asks for the new set. */
|
|
1192
|
+
reopenModernSubscription() {
|
|
1193
|
+
if (this.subscriptionFilterTimer)
|
|
1194
|
+
return;
|
|
1195
|
+
if (!this.modernSubscription) {
|
|
1196
|
+
// Nothing open to disturb: open now.
|
|
1197
|
+
if (this.subscriptionRestartTimer)
|
|
1198
|
+
clearTimeout(this.subscriptionRestartTimer);
|
|
1199
|
+
this.subscriptionRestartTimer = undefined;
|
|
1200
|
+
this.startModernSubscription();
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
// Coalesced: a host that subscribes and unsubscribes in a burst reopens the
|
|
1204
|
+
// stream once, and not at all when the set ends where it began, so there
|
|
1205
|
+
// is no gap in which a change can be missed.
|
|
1206
|
+
this.subscriptionFilterTimer = setTimeout(() => {
|
|
1207
|
+
this.subscriptionFilterTimer = undefined;
|
|
1208
|
+
if (this.status !== 'connected' || this.era?.kind !== 'modern')
|
|
1209
|
+
return;
|
|
1210
|
+
const wanted = subscriptionFilter(this.serverCapabilities, this.subscribedResources);
|
|
1211
|
+
const current = this.modernSubscription;
|
|
1212
|
+
if (current && JSON.stringify(current.requested) === JSON.stringify(wanted))
|
|
1213
|
+
return;
|
|
1214
|
+
if (this.subscriptionRestartTimer)
|
|
1215
|
+
clearTimeout(this.subscriptionRestartTimer);
|
|
1216
|
+
this.subscriptionRestartTimer = undefined;
|
|
1217
|
+
if (current)
|
|
1218
|
+
this.finishModernSubscription(current, 'filter changed', true, false);
|
|
1219
|
+
this.startModernSubscription();
|
|
1220
|
+
}, 0);
|
|
1221
|
+
this.subscriptionFilterTimer.unref?.();
|
|
1222
|
+
}
|
|
1223
|
+
/** A new legacy session holds no subscriptions; ask again for the ones the host wanted. */
|
|
1224
|
+
async resubscribeLegacyResources() {
|
|
1225
|
+
if (this.serverCapabilities?.resources?.subscribe !== true)
|
|
1226
|
+
return;
|
|
1227
|
+
for (const uri of this.subscribedResources) {
|
|
1228
|
+
try {
|
|
1229
|
+
await this.request('resources/subscribe', { uri });
|
|
1230
|
+
}
|
|
1231
|
+
catch (err) {
|
|
1232
|
+
this.log.warn('MCP resource subscription was not restored after reconnecting', {
|
|
1233
|
+
'namzu.connector.server': this.config.serverName,
|
|
1234
|
+
'exception.message': toErrorMessage(err),
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
893
1239
|
async listResourceTemplates(options) {
|
|
894
1240
|
this.requireConnected();
|
|
895
1241
|
return await this.listAllPages('resources/templates/list', 'resourceTemplates', options);
|
|
896
1242
|
}
|
|
1243
|
+
/**
|
|
1244
|
+
* `request()`, answered from memory when the server said it may be.
|
|
1245
|
+
*
|
|
1246
|
+
* A 2026-07-28 server puts `ttlMs` (milliseconds the result may be reused)
|
|
1247
|
+
* and `cacheScope` on what `tools/list`, `prompts/list`, `resources/list`,
|
|
1248
|
+
* `resources/templates/list` and `resources/read` return. Before this the
|
|
1249
|
+
* client ignored both and asked on every call. Now a result is kept for
|
|
1250
|
+
* exactly `ttlMs` after it arrived and asked for again after that, never
|
|
1251
|
+
* longer. Nothing is kept when `ttlMs` is absent, zero or negative (the
|
|
1252
|
+
* spec reads all three as "stale now"), when the result is not
|
|
1253
|
+
* `resultType: "complete"`, or on a connection that is not 2026-07-28 —
|
|
1254
|
+
* an older server sends no hints, so its lists are fetched every time, as
|
|
1255
|
+
* they always were.
|
|
1256
|
+
*
|
|
1257
|
+
* The key is the method plus its parameters, so each page of a list
|
|
1258
|
+
* (its cursor) and each resource (its uri) is its own entry. A change
|
|
1259
|
+
* notification drops what it makes stale at once, and a `private` result
|
|
1260
|
+
* is served only to a call with the same `Authorization` as the one that
|
|
1261
|
+
* fetched it; a result with no `cacheScope` is treated as `private`.
|
|
1262
|
+
*/
|
|
1263
|
+
async cachedRequest(method, params, options) {
|
|
1264
|
+
if (this.era?.kind !== 'modern' || !CACHEABLE_METHODS.has(method)) {
|
|
1265
|
+
return await this.requestWithSessionRecovery(method, params, options);
|
|
1266
|
+
}
|
|
1267
|
+
const key = `${method}\u0000${JSON.stringify(params)}`;
|
|
1268
|
+
const authorization = this.authorizationOf(options);
|
|
1269
|
+
const hit = this.resultCache.get(key);
|
|
1270
|
+
if (hit) {
|
|
1271
|
+
const sameAudience = hit.scope === 'public' || hit.scope.authorization === authorization;
|
|
1272
|
+
if (sameAudience && Date.now() < hit.expiresAt)
|
|
1273
|
+
return structuredClone(hit.value);
|
|
1274
|
+
this.resultCache.delete(key);
|
|
1275
|
+
}
|
|
1276
|
+
const epoch = this.cacheEpoch;
|
|
1277
|
+
const result = await this.requestWithSessionRecovery(method, params, options);
|
|
1278
|
+
const record = result;
|
|
1279
|
+
const ttlMs = record?.ttlMs;
|
|
1280
|
+
if (typeof ttlMs === 'number' &&
|
|
1281
|
+
Number.isFinite(ttlMs) &&
|
|
1282
|
+
ttlMs > 0 &&
|
|
1283
|
+
epoch === this.cacheEpoch &&
|
|
1284
|
+
(record?.resultType === undefined || record.resultType === 'complete')) {
|
|
1285
|
+
if (this.resultCache.size >= MAX_CACHE_ENTRIES) {
|
|
1286
|
+
const oldest = this.resultCache.keys().next().value;
|
|
1287
|
+
if (oldest !== undefined)
|
|
1288
|
+
this.resultCache.delete(oldest);
|
|
1289
|
+
}
|
|
1290
|
+
this.resultCache.set(key, {
|
|
1291
|
+
// A copy, so a caller who edits what it was given does not edit what is kept.
|
|
1292
|
+
value: structuredClone(result),
|
|
1293
|
+
expiresAt: Date.now() + Math.min(ttlMs, MAX_CACHE_TTL_MS),
|
|
1294
|
+
scope: record?.cacheScope === 'public' ? 'public' : { authorization },
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
return result;
|
|
1298
|
+
}
|
|
1299
|
+
/** The credential a call carries, for telling one caller's private results from another's. */
|
|
1300
|
+
authorizationOf(options) {
|
|
1301
|
+
if (options?.bearerToken !== undefined)
|
|
1302
|
+
return `Bearer ${options.bearerToken}`;
|
|
1303
|
+
const headers = options?.headers ?? {};
|
|
1304
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1305
|
+
if (name.toLowerCase() === 'authorization')
|
|
1306
|
+
return value;
|
|
1307
|
+
}
|
|
1308
|
+
return '';
|
|
1309
|
+
}
|
|
1310
|
+
/** Forget what a notification says is out of date. */
|
|
1311
|
+
invalidateCacheFor(notification, params) {
|
|
1312
|
+
const methods = INVALIDATED_BY[notification];
|
|
1313
|
+
if (!methods)
|
|
1314
|
+
return;
|
|
1315
|
+
this.cacheEpoch++;
|
|
1316
|
+
const uri = notification === 'notifications/resources/updated' ? params?.uri : undefined;
|
|
1317
|
+
for (const key of [...this.resultCache.keys()]) {
|
|
1318
|
+
const method = key.slice(0, key.indexOf('\u0000'));
|
|
1319
|
+
if (!methods.includes(method))
|
|
1320
|
+
continue;
|
|
1321
|
+
if (typeof uri === 'string' && !key.endsWith(`\u0000${JSON.stringify({ uri })}`))
|
|
1322
|
+
continue;
|
|
1323
|
+
this.resultCache.delete(key);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
/** Forget every cached method, for a connection that may be a different server instance. */
|
|
1327
|
+
clearResultCache(method) {
|
|
1328
|
+
this.cacheEpoch++;
|
|
1329
|
+
if (method === undefined) {
|
|
1330
|
+
this.resultCache.clear();
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
for (const key of [...this.resultCache.keys()]) {
|
|
1334
|
+
if (key.startsWith(`${method}\u0000`))
|
|
1335
|
+
this.resultCache.delete(key);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
897
1338
|
/**
|
|
898
1339
|
* Read a paged list to the end.
|
|
899
1340
|
*
|
|
@@ -915,7 +1356,7 @@ export class MCPClient {
|
|
|
915
1356
|
const items = [];
|
|
916
1357
|
let cursor;
|
|
917
1358
|
for (let page = 1;; page++) {
|
|
918
|
-
const result = (await this.
|
|
1359
|
+
const result = (await this.cachedRequest(method, cursor === undefined ? {} : { cursor }, options));
|
|
919
1360
|
const batch = result[field];
|
|
920
1361
|
if (Array.isArray(batch))
|
|
921
1362
|
items.push(...batch);
|
|
@@ -1024,13 +1465,19 @@ export class MCPClient {
|
|
|
1024
1465
|
const progressToken = method === 'tools/call' && options?.onProgress ? randomUUID() : undefined;
|
|
1025
1466
|
// Only a call that can receive progress can have its deadline extended.
|
|
1026
1467
|
const extendable = this.resetTimeoutOnProgress && progressToken !== undefined;
|
|
1027
|
-
|
|
1468
|
+
// The 2026-07-28 convention for carrying a trace across a request: the
|
|
1469
|
+
// W3C keys in `_meta`. Only on that revision, and only when a span is
|
|
1470
|
+
// active; an older server is sent exactly what it always was.
|
|
1471
|
+
const trace = this.era?.kind === 'modern' ? getTraceContextHeaders() : {};
|
|
1472
|
+
const hasTrace = trace.traceparent !== undefined;
|
|
1473
|
+
const requestParams = progressToken === undefined && !hasTrace
|
|
1028
1474
|
? params
|
|
1029
1475
|
: {
|
|
1030
1476
|
...params,
|
|
1031
1477
|
_meta: {
|
|
1478
|
+
...trace,
|
|
1032
1479
|
...params._meta,
|
|
1033
|
-
progressToken,
|
|
1480
|
+
...(progressToken === undefined ? {} : { progressToken }),
|
|
1034
1481
|
},
|
|
1035
1482
|
};
|
|
1036
1483
|
const envelope = buildEnvelope({
|
|
@@ -1038,7 +1485,7 @@ export class MCPClient {
|
|
|
1038
1485
|
method,
|
|
1039
1486
|
params: requestParams,
|
|
1040
1487
|
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
1041
|
-
capabilities: this.
|
|
1488
|
+
capabilities: this.clientCapabilities(),
|
|
1042
1489
|
...this.paramHeaderBindings(method, params),
|
|
1043
1490
|
});
|
|
1044
1491
|
const message = {
|
|
@@ -1281,7 +1728,7 @@ export class MCPClient {
|
|
|
1281
1728
|
method: 'notifications/cancelled',
|
|
1282
1729
|
params: { requestId: id, reason },
|
|
1283
1730
|
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
1284
|
-
capabilities: this.
|
|
1731
|
+
capabilities: this.clientCapabilities(),
|
|
1285
1732
|
});
|
|
1286
1733
|
const controller = new AbortController();
|
|
1287
1734
|
this.cancellationControllers.add(controller);
|
|
@@ -1349,7 +1796,7 @@ export class MCPClient {
|
|
|
1349
1796
|
method,
|
|
1350
1797
|
params,
|
|
1351
1798
|
clientInfo: this.config.clientInfo ?? NAMZU_CLIENT_INFO,
|
|
1352
|
-
capabilities: this.
|
|
1799
|
+
capabilities: this.clientCapabilities(),
|
|
1353
1800
|
});
|
|
1354
1801
|
const message = {
|
|
1355
1802
|
jsonrpc: '2.0',
|
|
@@ -1383,6 +1830,7 @@ export class MCPClient {
|
|
|
1383
1830
|
}
|
|
1384
1831
|
}
|
|
1385
1832
|
if (message.method && message.id === undefined) {
|
|
1833
|
+
this.invalidateCacheFor(message.method, message.params);
|
|
1386
1834
|
for (const handler of this.notificationHandlers) {
|
|
1387
1835
|
handler(message.method, message.params);
|
|
1388
1836
|
}
|
|
@@ -1401,6 +1849,15 @@ export class MCPClient {
|
|
|
1401
1849
|
void this.answerElicitation(message.id, message.params);
|
|
1402
1850
|
return;
|
|
1403
1851
|
}
|
|
1852
|
+
// `ping` is part of every revision before 2026-07-28 and must be answered
|
|
1853
|
+
// with an empty result; a server that gets an error back may decide the
|
|
1854
|
+
// connection is dead. The newest revision removed it.
|
|
1855
|
+
if (message.method === 'ping' && message.id !== undefined && this.era?.kind === 'legacy') {
|
|
1856
|
+
void this.transport.send({ jsonrpc: '2.0', id: message.id, result: {} }).catch((err) => {
|
|
1857
|
+
this.log.debug('Failed to send ping reply', { 'exception.message': toErrorMessage(err) });
|
|
1858
|
+
});
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1404
1861
|
if (message.method && message.id !== undefined) {
|
|
1405
1862
|
this.log.warn('Declining unsupported server-initiated MCP request', {
|
|
1406
1863
|
'namzu.connector.server': this.config.serverName,
|
|
@@ -1563,6 +2020,11 @@ export class MCPClient {
|
|
|
1563
2020
|
}
|
|
1564
2021
|
subscription.acknowledged = acknowledged;
|
|
1565
2022
|
clearTimeout(subscription.ackTimer);
|
|
2023
|
+
// A change may have landed before the stream went live.
|
|
2024
|
+
for (const [notification, acknowledgedKind] of Object.entries(SUBSCRIPTION_NOTIFICATIONS)) {
|
|
2025
|
+
if (acknowledged[acknowledgedKind])
|
|
2026
|
+
this.invalidateCacheFor(notification);
|
|
2027
|
+
}
|
|
1566
2028
|
for (const handler of [...this.notificationHandlers]) {
|
|
1567
2029
|
this.reconcileSubscription(subscription, handler);
|
|
1568
2030
|
}
|
|
@@ -1572,6 +2034,16 @@ export class MCPClient {
|
|
|
1572
2034
|
this.finishModernSubscription(subscription, 'notification arrived before acknowledgment', true);
|
|
1573
2035
|
return true;
|
|
1574
2036
|
}
|
|
2037
|
+
if (method === 'notifications/resources/updated') {
|
|
2038
|
+
const uri = message.params?.uri;
|
|
2039
|
+
if (typeof uri === 'string' &&
|
|
2040
|
+
subscription.acknowledged.resourceSubscriptions?.includes(uri) === true) {
|
|
2041
|
+
this.invalidateCacheFor(method, message.params);
|
|
2042
|
+
for (const handler of [...this.notificationHandlers])
|
|
2043
|
+
handler(method, message.params);
|
|
2044
|
+
return true;
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
1575
2047
|
if (!kind || subscription.acknowledged[kind] !== true) {
|
|
1576
2048
|
this.log.warn('Ignoring unrequested MCP subscription notification', {
|
|
1577
2049
|
'namzu.connector.server': this.config.serverName,
|
|
@@ -1579,6 +2051,7 @@ export class MCPClient {
|
|
|
1579
2051
|
});
|
|
1580
2052
|
return true;
|
|
1581
2053
|
}
|
|
2054
|
+
this.invalidateCacheFor(method, message.params);
|
|
1582
2055
|
for (const handler of [...this.notificationHandlers])
|
|
1583
2056
|
handler(method, message.params);
|
|
1584
2057
|
return true;
|
|
@@ -1588,8 +2061,10 @@ export class MCPClient {
|
|
|
1588
2061
|
if (this.modernSubscription !== subscription)
|
|
1589
2062
|
return;
|
|
1590
2063
|
for (const [method, kind] of Object.entries(SUBSCRIPTION_NOTIFICATIONS)) {
|
|
1591
|
-
if (subscription.acknowledged?.[kind])
|
|
2064
|
+
if (subscription.acknowledged?.[kind]) {
|
|
2065
|
+
this.invalidateCacheFor(method);
|
|
1592
2066
|
handler(method);
|
|
2067
|
+
}
|
|
1593
2068
|
}
|
|
1594
2069
|
}
|
|
1595
2070
|
requireConnected() {
|