tmcp 1.19.3 → 1.20.0-next.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/src/index.js CHANGED
@@ -4,36 +4,123 @@
4
4
  * @import SqidsType from "sqids";
5
5
  * @import { JSONRPCRequest, JSONRPCParams } from "json-rpc-2.0";
6
6
  * @import { ExtractURITemplateVariables } from "./internal/uri-template.js";
7
- * @import { CallToolResult as CallToolResultType, ReadResourceResult as ReadResourceResultType, GetPromptResult as GetPromptResultType, ServerInfo as ServerInfoType, ClientCapabilities as ClientCapabilitiesType, JSONRPCRequest as JSONRPCRequestType, JSONRPCResponse, CreateMessageRequestParams as CreateMessageRequestParamsType, CreateMessageResult as CreateMessageResultType, Resource as ResourceType, LoggingLevel as LoggingLevelType, ToolAnnotations, ClientInfo as ClientInfoType, ElicitResult as ElicitResultType, Icons as IconsType, JSONRPCMessage, InitializeResult as InitializeResultType, ListToolsResult as ListToolsResultType, ListPromptsResult as ListPromptsResultType, ListResourceTemplatesResult as ListResourceTemplatesResultType, ListResourcesResult as ListResourcesResultType, CompleteResult as CompleteResultType } from "./validation/index.js";
8
- * @import { Tool, Completion, Prompt, StoredResource, ServerOptions, SubscriptionsKeys, ChangedArgs, McpEvents, AllSame, TemplateOptions } from "./internal/internal.js";
7
+ * @import { CallToolResult as CallToolResultType, ReadResourceResult as ReadResourceResultType, GetPromptResult as GetPromptResultType, ServerInfo as ServerInfoType, ClientCapabilities as ClientCapabilitiesType, JSONRPCRequest as JSONRPCRequestType, JSONRPCResponse, CreateMessageRequestParams as CreateMessageRequestParamsType, CreateMessageResult as CreateMessageResultType, Resource as ResourceType, LoggingLevel as LoggingLevelType, ToolAnnotations, ClientInfo as ClientInfoType, ElicitResult as ElicitResultType, Icons as IconsType, JSONRPCMessage, InitializeResult as InitializeResultType, ListToolsResult as ListToolsResultType, ListPromptsResult as ListPromptsResultType, ListResourceTemplatesResult as ListResourceTemplatesResultType, ListResourcesResult as ListResourcesResultType, CompleteResult as CompleteResultType, SubscriptionFilter as SubscriptionFilterType, SubscriptionsListenRequest as SubscriptionsListenRequestType, SubscriptionsListenResult as SubscriptionsListenResultType, SubscriptionsAcknowledgedNotification as SubscriptionsAcknowledgedNotificationType } from "./validation/index.js";
8
+ * @import { Tool, Completion, Prompt, StoredResource, ServerOptions, SubscriptionsKeys, ChangedArgs, McpEvents, AllSame, TemplateOptions, MrtrState, Subscription as SubscriptionType, SubscriptionCallbacks as SubscriptionCallbacksType, SubscriptionManager as SubscriptionManagerType, SubscriptionOrigin as SubscriptionOriginType } from "./internal/internal.js";
9
9
  * @import { CreatedTool, ToolOptions, CreatedPrompt, PromptOptions, CreatedResource, CreatedTemplate, ResourceOptions } from "./internal/internal.js";
10
10
  */
11
- import { JSONRPCClient, JSONRPCServer } from 'json-rpc-2.0';
11
+ import {
12
+ createJSONRPCErrorResponse,
13
+ JSONRPCClient,
14
+ JSONRPCErrorException,
15
+ JSONRPCServer,
16
+ } from 'json-rpc-2.0';
12
17
  import { AsyncLocalStorage } from 'node:async_hooks';
13
18
  import { UriTemplateMatcher } from 'uri-template-matcher';
14
19
  import * as v from 'valibot';
15
20
  import {
16
21
  CallToolResultSchema,
22
+ CancelledNotificationSchema,
23
+ ClientCapabilitiesSchema,
17
24
  CompleteResultSchema,
18
25
  CreateMessageRequestParamsSchema,
19
26
  CreateMessageResultSchema,
20
27
  GetPromptResultSchema,
28
+ ImplementationSchema,
21
29
  InitializeRequestParamsSchema,
22
30
  JSONRPCNotificationSchema,
23
31
  JSONRPCRequestSchema,
24
32
  JSONRPCResponseSchema,
33
+ LoggingLevelSchema,
25
34
  McpError,
26
35
  ReadResourceResultSchema,
36
+ ElicitRequestSchema,
27
37
  ElicitResultSchema,
28
38
  JSONRPCErrorSchema,
39
+ InputResponsesSchema,
40
+ missing_required_client_capability_error,
41
+ SUBSCRIPTION_ID_META_KEY,
42
+ SubscriptionsListenRequestParamsSchema,
43
+ unsupported_protocol_version_error,
29
44
  } from './validation/index.js';
30
45
  import {
31
46
  get_supported_versions,
32
47
  negotiate_protocol_version,
48
+ KNOWN_PER_REQUEST_PROTOCOL_VERSIONS,
33
49
  } from './validation/version.js';
34
50
  import { should_version_negotiation_fail } from './validation/version.js';
51
+ import {
52
+ is_method_allowed,
53
+ CACHEABLE_METHODS,
54
+ MRTR_METHODS,
55
+ } from './validation/method-policy.js';
35
56
  import { event } from './internal/utils.js';
36
57
 
58
+ export {
59
+ McpError,
60
+ HEADER_MISMATCH,
61
+ MISSING_REQUIRED_CLIENT_CAPABILITY,
62
+ UNSUPPORTED_PROTOCOL_VERSION,
63
+ } from './validation/index.js';
64
+
65
+ /**
66
+ * Maximum length of the `requestState` text accepted from or returned to a
67
+ * client. This prevents a client from making the server process an
68
+ * excessively large value.
69
+ */
70
+ const MAX_ENCODED_REQUEST_STATE_LENGTH = 262144;
71
+ const RequestStateEnvelopeSchema = v.object({
72
+ version: v.literal(1),
73
+ inputResponses: InputResponsesSchema,
74
+ state: v.optional(v.unknown()),
75
+ });
76
+
77
+ /**
78
+ * Private error used to stop a handler when it needs input from the client.
79
+ * The server catches it and returns an `InputRequiredResult` instead of an
80
+ * error response. It is not exported, so application code cannot create a
81
+ * fake one.
82
+ */
83
+ class InputRequiredSignal extends Error {
84
+ constructor() {
85
+ super(
86
+ 'Input required: this request must end with an InputRequiredResult. If you see this message in a response, a catch block in a handler swallowed the internal input-required signal or the input call was not awaited — always await input calls and rethrow the signal (see isInputRequired()).',
87
+ );
88
+ this.name = 'InputRequiredSignal';
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Check whether an error means that tmcp is waiting for client input.
94
+ *
95
+ * When there is no open connection to the client, `elicitation()` and
96
+ * `message()` ask the client to retry the original request with the answer.
97
+ * They stop the current handler by throwing a private error. A broad `catch`
98
+ * in the handler must rethrow that error, or tmcp cannot ask for the retry.
99
+ * Use this helper to distinguish it from errors your handler should process:
100
+ *
101
+ * ```js
102
+ * try {
103
+ * const answer = await server.elicitation(msg, schema);
104
+ * } catch (error) {
105
+ * if (isInputRequired(error)) throw error;
106
+ * // handle the real error
107
+ * }
108
+ * ```
109
+ * @param {unknown} error
110
+ * @returns {boolean}
111
+ */
112
+ export function isInputRequired(error) {
113
+ return error instanceof InputRequiredSignal;
114
+ }
115
+
116
+ /**
117
+ * Return the per-request protocol versions supported by tmcp.
118
+ * @returns {string[]}
119
+ */
120
+ export function getPerRequestProtocolVersions() {
121
+ return [...KNOWN_PER_REQUEST_PROTOCOL_VERSIONS];
122
+ }
123
+
37
124
  /**
38
125
  * Information about a validated access token, provided to request handlers.
39
126
  * @typedef {Object} AuthInfo
@@ -52,10 +139,29 @@ import { event } from './internal/utils.js';
52
139
  * @typedef {Object} Context
53
140
  * @property {string} [sessionId]
54
141
  * @property {{ clientCapabilities?: ClientCapabilitiesType, clientInfo?: ClientInfoType, logLevel?: LoggingLevel }} [sessionInfo]
142
+ * @property {string} [protocolVersion] The exact per-request protocol version when the current request carries per-request protocol metadata; `undefined` for session-negotiated requests.
143
+ * @property {unknown} [requestState] Data saved by the handler before asking the client for input, then returned by the client on the retry. It is `undefined` when no data was saved or when the request uses an open session. The default JSON converter lets the client read and change this value. Do not store secrets in it or use it to make authorization decisions. Configure `requestStateCodec` if the server must detect changes.
144
+ * @property {AbortSignal} [signal] Aborted when the transport observes that the current request was cancelled.
55
145
  * @property {AuthInfo} [auth]
56
146
  * @property {TCustom} [custom]
57
147
  */
58
148
 
149
+ /**
150
+ * Context accepted by `receive()`. Subscription routing fields are
151
+ * transport-only and are deliberately omitted from `server.ctx`.
152
+ * @template {Record<string, unknown> | undefined} [TCustom=undefined]
153
+ * @typedef {Context<TCustom> & { subscriptionOrigin?: SubscriptionOriginType, subscriptionManager?: SubscriptionManagerType }} ReceiveContext
154
+ */
155
+
156
+ /** @typedef {SubscriptionFilterType} SubscriptionFilter */
157
+ /** @typedef {SubscriptionOriginType} SubscriptionOrigin */
158
+ /** @typedef {SubscriptionType} Subscription */
159
+ /** @typedef {SubscriptionCallbacksType} SubscriptionCallbacks */
160
+ /** @typedef {SubscriptionManagerType} SubscriptionManager */
161
+ /** @typedef {SubscriptionsListenRequestType} SubscriptionsListenRequest */
162
+ /** @typedef {SubscriptionsListenResultType} SubscriptionsListenResult */
163
+ /** @typedef {SubscriptionsAcknowledgedNotificationType} SubscriptionsAcknowledgedNotification */
164
+
59
165
  /**
60
166
  * @typedef {IconsType} Icons
61
167
  */
@@ -65,7 +171,7 @@ import { event } from './internal/utils.js';
65
171
  */
66
172
 
67
173
  /**
68
- * @template {Record<string, unknown> | undefined} TStructuredContent
174
+ * @template TStructuredContent
69
175
  * @typedef {CallToolResultType<TStructuredContent>} CallToolResult
70
176
  */
71
177
 
@@ -177,7 +283,13 @@ async function safe_enabled(enabled) {
177
283
  * @template {Record<string, unknown> | undefined} [CustomContext=undefined]
178
284
  */
179
285
  export class McpServer {
180
- #server = new JSONRPCServer();
286
+ #server = new JSONRPCServer({
287
+ errorListener: (...args) => {
288
+ const mrtr = this.#ctx_storage.getStore()?.mrtr;
289
+ if (mrtr !== undefined && args[1] === mrtr.signal) return;
290
+ console.warn(...args);
291
+ },
292
+ });
181
293
  /**
182
294
  * @type {JSONRPCClient<"broadcast" | "standalone"> | undefined}
183
295
  */
@@ -211,7 +323,12 @@ export class McpServer {
211
323
  #event_target = new EventTarget();
212
324
 
213
325
  /**
214
- * @type {AsyncLocalStorage<Context<CustomContext> & { progress_token?: string }>}
326
+ * @type {ServerInfo}
327
+ */
328
+ #server_info;
329
+
330
+ /**
331
+ * @type {AsyncLocalStorage<ReceiveContext<CustomContext> & { progress_token?: string, request_id?: string | number, stateless?: boolean, mrtr?: MrtrState }>}
215
332
  */
216
333
  #ctx_storage = new AsyncLocalStorage();
217
334
 
@@ -221,6 +338,35 @@ export class McpServer {
221
338
  */
222
339
  constructor(server_info, options) {
223
340
  this.#options = options;
341
+ this.#server_info = server_info;
342
+ // Remember when a handler stopped because it needs client input. After
343
+ // the JSON-RPC library finishes, `receive()` replaces that temporary
344
+ // error with an `InputRequiredResult`. All other errors keep their
345
+ // normal code and message.
346
+ this.#server.mapErrorToJSONRPCErrorResponse = (id, error) => {
347
+ const mrtr = this.#ctx_storage.getStore()?.mrtr;
348
+ if (
349
+ mrtr !== undefined &&
350
+ error === mrtr.signal &&
351
+ mrtr.pending.size > 0
352
+ ) {
353
+ mrtr.signal_at_boundary = true;
354
+ }
355
+ if (error instanceof JSONRPCErrorException) {
356
+ return createJSONRPCErrorResponse(
357
+ id,
358
+ error.code,
359
+ error.message,
360
+ error.data,
361
+ );
362
+ }
363
+ return createJSONRPCErrorResponse(
364
+ id,
365
+ -32603,
366
+ /** @type {Error | undefined} */ (error)?.message ??
367
+ 'An unexpected error occurred',
368
+ );
369
+ };
224
370
  this.#server.addMethod('initialize', (initialize_request) => {
225
371
  try {
226
372
  // Validate basic request format
@@ -254,9 +400,12 @@ export class McpServer {
254
400
  );
255
401
 
256
402
  // Return server response with negotiated version and capabilities
403
+ // (per-request protocol options are not part of the legacy initialize result)
404
+ // eslint-disable-next-line no-unused-vars
405
+ const { cache, ...legacy_options } = options;
257
406
  return {
258
407
  protocolVersion: negotiated_version,
259
- ...options,
408
+ ...legacy_options,
260
409
  serverInfo: server_info,
261
410
  };
262
411
  } catch (error) {
@@ -292,12 +441,24 @@ export class McpServer {
292
441
  this.#server.addMethod('notifications/initialized', () => {
293
442
  return null;
294
443
  });
444
+ this.#server.addMethod('server/discover', () => {
445
+ // only reachable for per-request (stateless) requests thanks to
446
+ // the method policy guard in `receive`
447
+ return {
448
+ supportedVersions: this.#enabled_protocol_versions(),
449
+ capabilities: this.#discover_capabilities(),
450
+ ...(this.#options.instructions != null
451
+ ? { instructions: this.#options.instructions }
452
+ : {}),
453
+ };
454
+ });
295
455
  this.#init_tools();
296
456
  this.#init_prompts();
297
457
  this.#init_resources();
298
458
  this.#init_roots();
299
459
  this.#init_completion();
300
460
  this.#init_logging();
461
+ this.#init_subscriptions();
301
462
  }
302
463
 
303
464
  /**
@@ -322,8 +483,20 @@ export class McpServer {
322
483
  * @type {Context<CustomContext>}
323
484
  */
324
485
  get ctx() {
325
- // eslint-disable-next-line no-unused-vars
326
- const { progress_token, ...rest } = this.#ctx_storage.getStore() ?? {};
486
+ /* eslint-disable no-unused-vars */
487
+ const {
488
+ progress_token,
489
+ request_id,
490
+ stateless,
491
+ mrtr,
492
+ subscriptionOrigin,
493
+ subscriptionManager,
494
+ ...rest
495
+ } = this.#ctx_storage.getStore() ?? {};
496
+ /* eslint-enable no-unused-vars */
497
+ if (mrtr !== undefined) {
498
+ rest.requestState = mrtr.incoming_state;
499
+ }
327
500
  return rest;
328
501
  }
329
502
 
@@ -480,6 +653,7 @@ export class McpServer {
480
653
 
481
654
  // Validate input arguments if schema is provided
482
655
  let validated_args = args;
656
+ this.#mark_mrtr_registration('tool', name, tool.replayable);
483
657
  if (tool.schema) {
484
658
  let validation_result =
485
659
  tool.schema['~standard'].validate(args);
@@ -617,8 +791,9 @@ export class McpServer {
617
791
  async ({ name, arguments: args }) => {
618
792
  const prompt = this.#prompts.get(name);
619
793
  if (!prompt) {
620
- throw new McpError(-32601, `Prompt ${name} not found`);
794
+ throw new McpError(-32602, `Prompt ${name} not found`);
621
795
  }
796
+ this.#mark_mrtr_registration('prompt', name, prompt.replayable);
622
797
  if (!prompt.schema) {
623
798
  return v.parse(
624
799
  GetPromptResultSchema,
@@ -747,9 +922,14 @@ export class McpServer {
747
922
  params = match.params;
748
923
  }
749
924
  if (!resource) {
750
- throw new McpError(-32601, `Resource ${uri} not found`);
925
+ throw new McpError(-32602, `Resource ${uri} not found`);
751
926
  }
752
927
  }
928
+ this.#mark_mrtr_registration(
929
+ resource.template ? 'template' : 'resource',
930
+ resource.name,
931
+ resource.replayable,
932
+ );
753
933
  if (resource.template) {
754
934
  if (!params)
755
935
  throw new McpError(
@@ -781,6 +961,12 @@ export class McpServer {
781
961
  * Request roots list from client
782
962
  */
783
963
  async #refresh_roots() {
964
+ if (this.#is_stateless) {
965
+ throw new McpError(
966
+ -32603,
967
+ 'Client roots are not available during per-request (stateless) execution: roots are deprecated in protocol version 2026-07-28 and tmcp does not emit roots/list as a multi round-trip input request',
968
+ );
969
+ }
784
970
  if (!this.#client_capabilities?.roots) return;
785
971
 
786
972
  this.#lazyily_create_client();
@@ -826,28 +1012,231 @@ export class McpServer {
826
1012
  });
827
1013
  }
828
1014
 
1015
+ /**
1016
+ * Reduce a requested filter to notification types enabled by this server.
1017
+ * @param {SubscriptionFilter} requested
1018
+ * @returns {SubscriptionFilter}
1019
+ */
1020
+ #subscription_filters(requested) {
1021
+ const capabilities = this.#options.capabilities;
1022
+ /** @type {SubscriptionFilter} */
1023
+ const filters = {};
1024
+ if (
1025
+ requested.toolsListChanged === true &&
1026
+ capabilities?.tools?.listChanged === true
1027
+ ) {
1028
+ filters.toolsListChanged = true;
1029
+ }
1030
+ if (
1031
+ requested.promptsListChanged === true &&
1032
+ capabilities?.prompts?.listChanged === true
1033
+ ) {
1034
+ filters.promptsListChanged = true;
1035
+ }
1036
+ if (
1037
+ requested.resourcesListChanged === true &&
1038
+ capabilities?.resources?.listChanged === true
1039
+ ) {
1040
+ filters.resourcesListChanged = true;
1041
+ }
1042
+ if (
1043
+ requested.resourceSubscriptions?.length &&
1044
+ capabilities?.resources?.subscribe === true
1045
+ ) {
1046
+ const resources = [
1047
+ ...new Set(requested.resourceSubscriptions),
1048
+ ].filter(
1049
+ (uri) =>
1050
+ this.#subscription_resource_registration(uri) !== undefined,
1051
+ );
1052
+ if (resources.length > 0) filters.resourceSubscriptions = resources;
1053
+ }
1054
+ return filters;
1055
+ }
1056
+
1057
+ /**
1058
+ * Resolve a static resource or a concrete URI handled by a template. The
1059
+ * template pattern itself is not a subscribable resource URI.
1060
+ * @param {string} uri
1061
+ */
1062
+ #subscription_resource_registration(uri) {
1063
+ const resource = this.#resources.get(uri);
1064
+ if (resource) return resource.template ? undefined : resource;
1065
+ const match = this.#templates.match(uri);
1066
+ return match ? this.#resources.get(match.template) : undefined;
1067
+ }
1068
+
1069
+ /**
1070
+ * Register the long-lived per-request subscription method and cancellation
1071
+ * notification. Transports keep the response sink open while the returned
1072
+ * promise is pending.
1073
+ */
1074
+ #init_subscriptions() {
1075
+ this.#server.addMethod('subscriptions/listen', async (params) => {
1076
+ const parsed = v.safeParse(
1077
+ SubscriptionsListenRequestParamsSchema,
1078
+ params,
1079
+ );
1080
+ if (!parsed.success) {
1081
+ throw new McpError(
1082
+ -32602,
1083
+ 'Invalid subscriptions/listen params: expected a notifications filter',
1084
+ );
1085
+ }
1086
+ const id = this.#ctx_storage.getStore()?.request_id;
1087
+ if (id === undefined) {
1088
+ throw new McpError(
1089
+ -32600,
1090
+ 'subscriptions/listen must be a JSON-RPC request with an id',
1091
+ );
1092
+ }
1093
+ const store = this.#ctx_storage.getStore();
1094
+ const origin = store?.subscriptionOrigin;
1095
+ const manager = store?.subscriptionManager;
1096
+ if (typeof origin !== 'string' || manager === undefined) {
1097
+ throw new McpError(
1098
+ -32603,
1099
+ 'subscriptions/listen requires the transport to provide a subscriptionManager and stable string subscriptionOrigin for this connection',
1100
+ );
1101
+ }
1102
+ const filters = this.#subscription_filters(
1103
+ parsed.output.notifications,
1104
+ );
1105
+ /** @type {(result: Record<string, unknown>)=>void} */
1106
+ let settle = () => {};
1107
+ const pending = new Promise((resolve) => {
1108
+ settle = resolve;
1109
+ });
1110
+ const created = await manager.create(
1111
+ { id, origin, filters },
1112
+ {
1113
+ acknowledge: () =>
1114
+ this.#subscription_send(id, origin, {
1115
+ jsonrpc: '2.0',
1116
+ method: 'notifications/subscriptions/acknowledged',
1117
+ params: {
1118
+ notifications: filters,
1119
+ _meta: {
1120
+ [SUBSCRIPTION_ID_META_KEY]: id,
1121
+ },
1122
+ },
1123
+ }),
1124
+ send: (notification) =>
1125
+ this.#subscription_send(
1126
+ id,
1127
+ origin,
1128
+ this.#tag_subscription(notification, id),
1129
+ ),
1130
+ close: () => {
1131
+ settle({
1132
+ _meta: { [SUBSCRIPTION_ID_META_KEY]: id },
1133
+ });
1134
+ },
1135
+ },
1136
+ );
1137
+ if (!created) {
1138
+ throw new McpError(
1139
+ -32602,
1140
+ `A subscription with id ${JSON.stringify(id)} is already active for this connection`,
1141
+ );
1142
+ }
1143
+ return pending;
1144
+ });
1145
+
1146
+ this.#server.addMethod('notifications/cancelled', async (params) => {
1147
+ const cancellation = v.safeParse(
1148
+ CancelledNotificationSchema.entries.params,
1149
+ params,
1150
+ );
1151
+ const store = this.#ctx_storage.getStore();
1152
+ const origin = store?.subscriptionOrigin;
1153
+ const manager = store?.subscriptionManager;
1154
+ if (
1155
+ cancellation.success &&
1156
+ origin !== undefined &&
1157
+ manager !== undefined
1158
+ ) {
1159
+ await manager.close(
1160
+ cancellation.output.requestId,
1161
+ origin,
1162
+ 'cancelled',
1163
+ );
1164
+ }
1165
+ return null;
1166
+ });
1167
+ }
1168
+
1169
+ /**
1170
+ * @param {JSONRPCRequest} notification
1171
+ * @param {string | number} subscription_id
1172
+ */
1173
+ #tag_subscription(notification, subscription_id) {
1174
+ const params = /** @type {Record<string, unknown>} */ (
1175
+ notification.params ?? {}
1176
+ );
1177
+ return {
1178
+ ...notification,
1179
+ params: {
1180
+ ...params,
1181
+ _meta: {
1182
+ .../** @type {Record<string, unknown> | undefined} */ (
1183
+ params._meta
1184
+ ),
1185
+ [SUBSCRIPTION_ID_META_KEY]: subscription_id,
1186
+ },
1187
+ },
1188
+ };
1189
+ }
1190
+
1191
+ /**
1192
+ * @param {string | number} subscription_id
1193
+ * @param {string} origin
1194
+ * @param {JSONRPCRequest} request
1195
+ */
1196
+ #subscription_send(subscription_id, origin, request) {
1197
+ this.#event_target.dispatchEvent(
1198
+ event('send', {
1199
+ subscriptionId: subscription_id,
1200
+ subscriptionOrigin: origin,
1201
+ request,
1202
+ }),
1203
+ );
1204
+ }
1205
+
829
1206
  #notify_tools_list_changed() {
830
1207
  if (this.#options.capabilities?.tools?.listChanged) {
831
- this.#notify('notifications/tools/list_changed', {}, 'broadcast');
1208
+ this.#broadcast_change('notifications/tools/list_changed', {});
832
1209
  }
833
1210
  }
834
1211
 
835
1212
  #notify_prompts_list_changed() {
836
1213
  if (this.#options.capabilities?.prompts?.listChanged) {
837
- this.#notify('notifications/prompts/list_changed', {}, 'broadcast');
1214
+ this.#broadcast_change('notifications/prompts/list_changed', {});
838
1215
  }
839
1216
  }
840
1217
 
841
1218
  #notify_resources_list_changed() {
842
1219
  if (this.#options.capabilities?.resources?.listChanged) {
843
- this.#notify(
844
- 'notifications/resources/list_changed',
845
- {},
846
- 'broadcast',
847
- );
1220
+ this.#broadcast_change('notifications/resources/list_changed', {});
848
1221
  }
849
1222
  }
850
1223
 
1224
+ /**
1225
+ * Publish a change through the existing broadcast lifecycle. Transports use
1226
+ * `subscriptionOnly` to keep concrete template updates off legacy streams.
1227
+ * @param {string} method
1228
+ * @param {Record<string, unknown>} params
1229
+ * @param {boolean} [subscription_only]
1230
+ */
1231
+ #broadcast_change(method, params, subscription_only = false) {
1232
+ this.#event_target.dispatchEvent(
1233
+ event('broadcast', {
1234
+ request: { jsonrpc: '2.0', method, params },
1235
+ ...(subscription_only ? { subscriptionOnly: true } : {}),
1236
+ }),
1237
+ );
1238
+ }
1239
+
851
1240
  /**
852
1241
  * Use the `defineTool` utility to create a reusable tool and pass it to this method to add it to the server.
853
1242
  * @template {Array<CreatedTool<any, any>>} T
@@ -941,6 +1330,28 @@ export class McpServer {
941
1330
  this.#tools.set(tool_or_options.name, stored_tool);
942
1331
  }
943
1332
 
1333
+ /**
1334
+ * Run transport-specific validation against a registered tool's JSON
1335
+ * Schema without enabling, validating, or executing the tool.
1336
+ * @param {string} name
1337
+ * @param {Record<string, unknown>} args
1338
+ * @param {(input_schema: Record<string, unknown>, args: Record<string, unknown>)=>void | Promise<void>} validator
1339
+ * @returns {Promise<boolean>}
1340
+ */
1341
+ async validateToolCall(name, args, validator) {
1342
+ const tool = this.#tools.get(name);
1343
+ if (!tool) return false;
1344
+ const input_schema =
1345
+ tool.schema && this.#options.adapter
1346
+ ? await this.#options.adapter.toJsonSchema(tool.schema)
1347
+ : { type: 'object', properties: {} };
1348
+ await validator(
1349
+ /** @type {Record<string, unknown>} */ (input_schema),
1350
+ args,
1351
+ );
1352
+ return true;
1353
+ }
1354
+
944
1355
  /**
945
1356
  * Add a prompt to the server. Prompts are used to provide the user with pre-defined messages that adds context to the LLM.
946
1357
  * Use the description and title to help the user to understand what the prompt does and when to use it.
@@ -1101,11 +1512,127 @@ export class McpServer {
1101
1512
  stored_template.template = true;
1102
1513
  this.#resource(stored_template);
1103
1514
  }
1515
+ /**
1516
+ * The per-request protocol versions supported by tmcp.
1517
+ * @returns {string[]}
1518
+ */
1519
+ #enabled_protocol_versions() {
1520
+ return getPerRequestProtocolVersions();
1521
+ }
1522
+
1523
+ /**
1524
+ * Check whether a JSON-RPC method is registered without invoking it.
1525
+ * @param {string} method
1526
+ */
1527
+ hasMethod(method) {
1528
+ return this.#server.hasMethod(method);
1529
+ }
1530
+
1531
+ /**
1532
+ * Derive capabilities advertised through `server/discover`. Subscription
1533
+ * flags stay hidden until the active transport can route long-lived listen
1534
+ * streams; core support alone cannot guarantee delivery.
1535
+ * @returns {Record<string, unknown>}
1536
+ */
1537
+ #discover_capabilities() {
1538
+ const store = this.#ctx_storage.getStore();
1539
+ if (
1540
+ store?.subscriptionManager !== undefined &&
1541
+ typeof store.subscriptionOrigin === 'string'
1542
+ ) {
1543
+ return { ...(this.#options.capabilities ?? {}) };
1544
+ }
1545
+ const { resources, tools, prompts, ...rest } =
1546
+ this.#options.capabilities ?? {};
1547
+ /** @type {Record<string, unknown>} */
1548
+ const capabilities = { ...rest };
1549
+ if (resources) {
1550
+ // eslint-disable-next-line no-unused-vars
1551
+ const { subscribe, listChanged, ...resources_rest } = resources;
1552
+ capabilities.resources = resources_rest;
1553
+ }
1554
+ if (tools) {
1555
+ // eslint-disable-next-line no-unused-vars
1556
+ const { listChanged, ...tools_rest } = tools;
1557
+ capabilities.tools = tools_rest;
1558
+ }
1559
+ if (prompts) {
1560
+ // eslint-disable-next-line no-unused-vars
1561
+ const { listChanged, ...prompts_rest } = prompts;
1562
+ capabilities.prompts = prompts_rest;
1563
+ }
1564
+ return capabilities;
1565
+ }
1566
+
1567
+ /**
1568
+ * Resolve the effective cache policy for a cacheable method.
1569
+ * @param {string} method
1570
+ * @returns {{ ttlMs: number, cacheScope: 'public' | 'private' }}
1571
+ */
1572
+ #cache_policy(method) {
1573
+ const cache = this.#options.cache;
1574
+ const override = cache?.methods?.[method];
1575
+ return {
1576
+ ttlMs: override?.ttlMs ?? cache?.ttlMs ?? 0,
1577
+ cacheScope: override?.cacheScope ?? cache?.cacheScope ?? 'private',
1578
+ };
1579
+ }
1580
+
1581
+ /**
1582
+ * Decorate a successful per-request (stateless) wire result:
1583
+ * `resultType` (preserving handler-provided string extension values,
1584
+ * anything else becomes `'complete'`), serverInfo `_meta` merge, and
1585
+ * `ttlMs`/`cacheScope` for cacheable methods. The cache fields are
1586
+ * ALWAYS taken from the configured policy, overwriting any
1587
+ * handler-provided values: handlers are profile-unaware and must not be
1588
+ * able to opt results into public caching.
1589
+ * @param {string} method
1590
+ * @param {Record<string, unknown>} result
1591
+ * @param {boolean} [private_result]
1592
+ */
1593
+ #decorate_result(method, result, private_result = false) {
1594
+ if (typeof result.resultType !== 'string') {
1595
+ result.resultType = 'complete';
1596
+ }
1597
+ result._meta = {
1598
+ .../** @type {Record<string, unknown> | undefined} */ (
1599
+ result._meta
1600
+ ),
1601
+ 'io.modelcontextprotocol/serverInfo': this.#server_info,
1602
+ };
1603
+ if (CACHEABLE_METHODS.has(method)) {
1604
+ const policy = private_result
1605
+ ? { ttlMs: 0, cacheScope: /** @type {const} */ ('private') }
1606
+ : this.#cache_policy(method);
1607
+ result.ttlMs = policy.ttlMs;
1608
+ result.cacheScope = policy.cacheScope;
1609
+ }
1610
+ }
1611
+
1612
+ /**
1613
+ * Build a JSON-RPC error response for a request, or `null` for a
1614
+ * notification (which cannot receive a response).
1615
+ * @param {string | number | undefined} id
1616
+ * @param {McpError} error
1617
+ */
1618
+ #error_response(id, error) {
1619
+ if (id === undefined) return Promise.resolve(null);
1620
+ return Promise.resolve({
1621
+ jsonrpc: /** @type {const} */ ('2.0'),
1622
+ id,
1623
+ error: {
1624
+ code: error.code,
1625
+ message: error.message,
1626
+ ...(error.data !== undefined ? { data: error.data } : {}),
1627
+ },
1628
+ });
1629
+ }
1630
+
1104
1631
  /**
1105
1632
  * The main function that receive a JSONRpc message and either dispatch a `send` event or process the request.
1106
1633
  *
1107
1634
  * @param {JSONRPCMessage} message
1108
- * @param {Context<CustomContext>} [ctx]
1635
+ * @param {ReceiveContext<CustomContext>} [ctx]
1109
1636
  * @returns {ReturnType<JSONRPCServer['receive']> | ReturnType<JSONRPCClient['receive'] | undefined>}
1110
1637
  */
1111
1638
  receive(message, ctx) {
@@ -1117,14 +1644,422 @@ export class McpServer {
1117
1644
 
1118
1645
  // Check if it's a request or response
1119
1646
  if (validated_message.success) {
1647
+ const request_message = validated_message.output;
1648
+ const id = 'id' in request_message ? request_message.id : undefined;
1649
+ const meta = request_message.params?._meta;
1120
1650
  const progress_token = /** @type {string | undefined} */ (
1121
- validated_message.output.params?._meta?.progressToken
1651
+ meta?.progressToken
1122
1652
  );
1123
- return this.#ctx_storage.run(
1124
- { ...(ctx ?? {}), progress_token },
1125
- async () =>
1126
- await this.#server.receive(validated_message.output),
1653
+ const requested_version =
1654
+ meta?.['io.modelcontextprotocol/protocolVersion'];
1655
+ const request_capabilities =
1656
+ meta?.['io.modelcontextprotocol/clientCapabilities'];
1657
+ const request_client_info =
1658
+ meta?.['io.modelcontextprotocol/clientInfo'];
1659
+ const request_log_level =
1660
+ meta?.['io.modelcontextprotocol/logLevel'];
1661
+
1662
+ // `protocolVersion` and the internal `stateless` flag are set
1663
+ // exclusively by the classification below: transport-provided
1664
+ // values must never make a session-negotiated request look like
1665
+ // a per-request one
1666
+ const {
1667
+ // eslint-disable-next-line no-unused-vars
1668
+ protocolVersion,
1669
+ // eslint-disable-next-line no-unused-vars
1670
+ stateless: _,
1671
+ ...transport_ctx
1672
+ } = /** @type {ReceiveContext<CustomContext> & { stateless?: boolean }} */ (
1673
+ ctx ?? {}
1127
1674
  );
1675
+ /** @type {ReceiveContext<CustomContext> & { progress_token?: string, request_id?: string | number, stateless?: boolean, mrtr?: MrtrState }} */
1676
+ let store = { ...transport_ctx, progress_token, request_id: id };
1677
+ // classify the request: the presence of ANY reserved per-request
1678
+ // _meta key enters the per-request path — incomplete or invalid
1679
+ // metadata is rejected, never silently merged or downgraded to
1680
+ // the session path
1681
+ if (
1682
+ requested_version !== undefined ||
1683
+ request_capabilities !== undefined ||
1684
+ request_client_info !== undefined ||
1685
+ request_log_level !== undefined
1686
+ ) {
1687
+ if (
1688
+ requested_version === undefined ||
1689
+ request_capabilities === undefined
1690
+ ) {
1691
+ return this.#error_response(
1692
+ id,
1693
+ new McpError(
1694
+ -32602,
1695
+ 'Requests with per-request protocol metadata must include both "io.modelcontextprotocol/protocolVersion" and "io.modelcontextprotocol/clientCapabilities" in _meta',
1696
+ ),
1697
+ );
1698
+ }
1699
+ if (typeof requested_version !== 'string') {
1700
+ return this.#error_response(
1701
+ id,
1702
+ new McpError(
1703
+ -32602,
1704
+ 'Invalid "io.modelcontextprotocol/protocolVersion" in _meta: expected a string',
1705
+ ),
1706
+ );
1707
+ }
1708
+ // `looseObject` alone would accept arrays, so check the
1709
+ // basic shape explicitly first
1710
+ const parsed_capabilities =
1711
+ typeof request_capabilities !== 'object' ||
1712
+ request_capabilities === null ||
1713
+ Array.isArray(request_capabilities)
1714
+ ? undefined
1715
+ : v.safeParse(
1716
+ ClientCapabilitiesSchema,
1717
+ request_capabilities,
1718
+ );
1719
+ if (!parsed_capabilities?.success) {
1720
+ return this.#error_response(
1721
+ id,
1722
+ new McpError(
1723
+ -32602,
1724
+ 'Invalid "io.modelcontextprotocol/clientCapabilities" in _meta: expected a client capabilities object',
1725
+ ),
1726
+ );
1727
+ }
1728
+ const parsed_client_info =
1729
+ request_client_info === undefined
1730
+ ? undefined
1731
+ : v.safeParse(
1732
+ ImplementationSchema,
1733
+ request_client_info,
1734
+ );
1735
+ if (parsed_client_info && !parsed_client_info.success) {
1736
+ return this.#error_response(
1737
+ id,
1738
+ new McpError(
1739
+ -32602,
1740
+ 'Invalid "io.modelcontextprotocol/clientInfo" in _meta: expected an implementation object with "name" and "version"',
1741
+ ),
1742
+ );
1743
+ }
1744
+ const parsed_log_level =
1745
+ request_log_level === undefined
1746
+ ? undefined
1747
+ : v.safeParse(LoggingLevelSchema, request_log_level);
1748
+ if (parsed_log_level && !parsed_log_level.success) {
1749
+ return this.#error_response(
1750
+ id,
1751
+ new McpError(
1752
+ -32602,
1753
+ 'Invalid "io.modelcontextprotocol/logLevel" in _meta: expected a logging level',
1754
+ ),
1755
+ );
1756
+ }
1757
+ const enabled = this.#enabled_protocol_versions();
1758
+ if (!enabled.includes(requested_version)) {
1759
+ return this.#error_response(
1760
+ id,
1761
+ unsupported_protocol_version_error(
1762
+ enabled,
1763
+ requested_version,
1764
+ ),
1765
+ );
1766
+ }
1767
+ // per-request info fully replaces any transport-provided
1768
+ // session info: stateless requests never inherit session state
1769
+ store = {
1770
+ ...store,
1771
+ stateless: true,
1772
+ protocolVersion: requested_version,
1773
+ sessionInfo: {
1774
+ clientCapabilities: parsed_capabilities.output,
1775
+ clientInfo: parsed_client_info?.output,
1776
+ logLevel: parsed_log_level?.output,
1777
+ },
1778
+ };
1779
+ }
1780
+
1781
+ const stateless = store.stateless === true;
1782
+ if (
1783
+ !is_method_allowed(request_message.method, stateless) ||
1784
+ !this.hasMethod(request_message.method)
1785
+ ) {
1786
+ return this.#error_response(
1787
+ id,
1788
+ new McpError(
1789
+ -32601,
1790
+ `Method ${request_message.method} not found`,
1791
+ ),
1792
+ );
1793
+ }
1794
+
1795
+ // Read retry answers and saved state before running the handler, then
1796
+ // remove them from its normal arguments. Only tool calls, prompt reads,
1797
+ // and resource reads can receive these fields.
1798
+ /** @type {string | undefined} */
1799
+ let encoded_incoming_state;
1800
+ const params = request_message.params;
1801
+ const raw_input_responses = params?.inputResponses;
1802
+ const raw_request_state = params?.requestState;
1803
+ if (
1804
+ !stateless &&
1805
+ (raw_input_responses !== undefined ||
1806
+ raw_request_state !== undefined)
1807
+ ) {
1808
+ return this.#error_response(
1809
+ id,
1810
+ new McpError(
1811
+ -32602,
1812
+ '"inputResponses" and "requestState" params are only supported by per-request (stateless) multi round-trip requests',
1813
+ ),
1814
+ );
1815
+ }
1816
+ if (stateless) {
1817
+ /** @type {Record<string, unknown> | undefined} */
1818
+ let input_responses;
1819
+ if (
1820
+ raw_input_responses !== undefined ||
1821
+ raw_request_state !== undefined
1822
+ ) {
1823
+ if (!MRTR_METHODS.has(request_message.method)) {
1824
+ return this.#error_response(
1825
+ id,
1826
+ new McpError(
1827
+ -32602,
1828
+ `"inputResponses" and "requestState" params are only accepted on multi round-trip methods (${[...MRTR_METHODS].join(', ')})`,
1829
+ ),
1830
+ );
1831
+ }
1832
+ if (raw_input_responses !== undefined) {
1833
+ const parsed = v.safeParse(
1834
+ InputResponsesSchema,
1835
+ raw_input_responses,
1836
+ );
1837
+ if (!parsed.success) {
1838
+ return this.#error_response(
1839
+ id,
1840
+ new McpError(
1841
+ -32602,
1842
+ 'Invalid "inputResponses": expected a map of input response objects keyed by input request key',
1843
+ ),
1844
+ );
1845
+ }
1846
+ input_responses = parsed.output;
1847
+ }
1848
+ if (raw_request_state !== undefined) {
1849
+ if (typeof raw_request_state !== 'string') {
1850
+ return this.#error_response(
1851
+ id,
1852
+ new McpError(
1853
+ -32602,
1854
+ 'Invalid "requestState": expected the opaque string previously returned by the server',
1855
+ ),
1856
+ );
1857
+ }
1858
+ if (
1859
+ raw_request_state.length >
1860
+ MAX_ENCODED_REQUEST_STATE_LENGTH
1861
+ ) {
1862
+ return this.#error_response(
1863
+ id,
1864
+ new McpError(
1865
+ -32602,
1866
+ `Invalid "requestState": encoded state exceeds the maximum accepted length of ${MAX_ENCODED_REQUEST_STATE_LENGTH} characters`,
1867
+ ),
1868
+ );
1869
+ }
1870
+ encoded_incoming_state = raw_request_state;
1871
+ }
1872
+ if (params) {
1873
+ delete params.inputResponses;
1874
+ delete params.requestState;
1875
+ }
1876
+ }
1877
+ if (MRTR_METHODS.has(request_message.method)) {
1878
+ store.mrtr = {
1879
+ input_responses,
1880
+ incoming_state: undefined,
1881
+ ordinal: 0,
1882
+ used_keys: new Set(),
1883
+ pending: new Map(),
1884
+ consumed_responses: new Map(),
1885
+ outgoing_state: undefined,
1886
+ registration: undefined,
1887
+ input_error: undefined,
1888
+ signal: new InputRequiredSignal(),
1889
+ signal_at_boundary: false,
1890
+ };
1891
+ }
1892
+ }
1893
+
1894
+ return this.#ctx_storage.run(store, async () => {
1895
+ const mrtr = store.mrtr;
1896
+ if (
1897
+ mrtr !== undefined &&
1898
+ encoded_incoming_state !== undefined
1899
+ ) {
1900
+ let decoded_state;
1901
+ try {
1902
+ decoded_state = await this.#request_state_codec.decode(
1903
+ encoded_incoming_state,
1904
+ );
1905
+ } catch (error) {
1906
+ return this.#error_response(
1907
+ id,
1908
+ new McpError(
1909
+ -32602,
1910
+ `Invalid "requestState": the configured codec failed to decode it (${/** @type {Error} */ (error)?.message})`,
1911
+ ),
1912
+ );
1913
+ }
1914
+ const parsed_state = v.safeParse(
1915
+ RequestStateEnvelopeSchema,
1916
+ decoded_state,
1917
+ );
1918
+ if (!parsed_state.success) {
1919
+ return this.#error_response(
1920
+ id,
1921
+ new McpError(
1922
+ -32602,
1923
+ 'Invalid "requestState": expected state previously returned by tmcp',
1924
+ ),
1925
+ );
1926
+ }
1927
+ mrtr.incoming_state = parsed_state.output.state;
1928
+ mrtr.outgoing_state = parsed_state.output.state;
1929
+ mrtr.input_responses = Object.assign(
1930
+ Object.create(null),
1931
+ parsed_state.output.inputResponses,
1932
+ mrtr.input_responses,
1933
+ );
1934
+ }
1935
+ const response = await this.#server.receive(request_message);
1936
+ if (mrtr?.input_error !== undefined) {
1937
+ return this.#error_response(id, mrtr.input_error);
1938
+ }
1939
+ if (
1940
+ mrtr !== undefined &&
1941
+ mrtr.pending.size > 0 &&
1942
+ response != null
1943
+ ) {
1944
+ if ('result' in response) {
1945
+ // The handler returned even though it still needs client input.
1946
+ // It probably caught tmcp's private error or forgot to await the
1947
+ // input call.
1948
+ return this.#error_response(
1949
+ id,
1950
+ new McpError(
1951
+ -32603,
1952
+ `Handler${mrtr.registration ? ` for ${mrtr.registration.kind} "${mrtr.registration.name}"` : ''} returned a result while input requests are pending: a catch block swallowed the internal input-required signal thrown by elicitation()/message(), or an input call was not awaited. Always await input calls. Catch blocks must rethrow the signal — use isInputRequired(error) to detect it and rethrow selectively.`,
1953
+ ),
1954
+ );
1955
+ }
1956
+ if (mrtr.signal_at_boundary) {
1957
+ /** @type {Record<string, { method: string, params: Record<string, unknown> }>} */
1958
+ let input_requests;
1959
+ try {
1960
+ const entries = await Promise.all(
1961
+ [...mrtr.pending].map(
1962
+ async ([key, request]) =>
1963
+ /** @type {[string, { method: string, params: Record<string, unknown> }]} */ ([
1964
+ key,
1965
+ await request,
1966
+ ]),
1967
+ ),
1968
+ );
1969
+ input_requests = Object.fromEntries(entries);
1970
+ } catch (error) {
1971
+ return this.#error_response(
1972
+ id,
1973
+ new McpError(
1974
+ -32603,
1975
+ `Failed to prepare an input request: ${/** @type {Error} */ (error)?.message ?? 'unknown error'}`,
1976
+ ),
1977
+ );
1978
+ }
1979
+ // Tell the client what input is needed. Do not add cache fields,
1980
+ // because this is not the final result and must not be cached.
1981
+ /** @type {Record<string, unknown>} */
1982
+ const result = {
1983
+ resultType: 'input_required',
1984
+ inputRequests: input_requests,
1985
+ _meta: {
1986
+ 'io.modelcontextprotocol/serverInfo':
1987
+ this.#server_info,
1988
+ },
1989
+ };
1990
+ if (
1991
+ mrtr.outgoing_state !== undefined ||
1992
+ mrtr.consumed_responses.size > 0
1993
+ ) {
1994
+ /** @type {string} */
1995
+ let encoded;
1996
+ try {
1997
+ // Clients only have to answer the latest inputRequests. Keep
1998
+ // answers already used by the handler so the next retry can
1999
+ // start from the top without asking the same questions again.
2000
+ encoded =
2001
+ await this.#request_state_codec.encode({
2002
+ version: 1,
2003
+ inputResponses: Object.fromEntries(
2004
+ mrtr.consumed_responses,
2005
+ ),
2006
+ ...(mrtr.outgoing_state !== undefined
2007
+ ? { state: mrtr.outgoing_state }
2008
+ : {}),
2009
+ });
2010
+ } catch (error) {
2011
+ return this.#error_response(
2012
+ id,
2013
+ new McpError(
2014
+ -32603,
2015
+ `Failed to encode requestState with the configured codec: ${/** @type {Error} */ (error)?.message}`,
2016
+ ),
2017
+ );
2018
+ }
2019
+ if (
2020
+ typeof encoded !== 'string' ||
2021
+ encoded.length >
2022
+ MAX_ENCODED_REQUEST_STATE_LENGTH
2023
+ ) {
2024
+ return this.#error_response(
2025
+ id,
2026
+ new McpError(
2027
+ -32603,
2028
+ `The requestState codec must produce a string of at most ${MAX_ENCODED_REQUEST_STATE_LENGTH} characters`,
2029
+ ),
2030
+ );
2031
+ }
2032
+ result.requestState = encoded;
2033
+ }
2034
+ return {
2035
+ jsonrpc: /** @type {const} */ ('2.0'),
2036
+ id: /** @type {string | number} */ (id),
2037
+ result,
2038
+ };
2039
+ }
2040
+ // an unrelated error won the race against (or replaced)
2041
+ // the signal: let it through unchanged
2042
+ }
2043
+ if (stateless && response != null && 'result' in response) {
2044
+ // a successful stateless response must always be a
2045
+ // decorated object result, even when the handler
2046
+ // resolved `null` (e.g. completion of an unknown ref)
2047
+ if (
2048
+ response.result == null ||
2049
+ typeof response.result !== 'object'
2050
+ ) {
2051
+ response.result = {};
2052
+ }
2053
+ this.#decorate_result(
2054
+ request_message.method,
2055
+ /** @type {Record<string, unknown>} */ (
2056
+ response.result
2057
+ ),
2058
+ (mrtr?.consumed_responses.size ?? 0) > 0,
2059
+ );
2060
+ }
2061
+ return response;
2062
+ });
1128
2063
  }
1129
2064
  // It's a response - handle with client
1130
2065
  const validated_response = v.parse(
@@ -1144,6 +2079,12 @@ export class McpServer {
1144
2079
  * @returns {Promise<unknown>}
1145
2080
  */
1146
2081
  async request({ method, params }) {
2082
+ if (this.#is_stateless) {
2083
+ throw new McpError(
2084
+ -32603,
2085
+ 'Low-level server-to-client requests are not supported for per-request protocol requests: there is no server-to-client JSON-RPC channel, and arbitrary requests cannot be translated into multi round-trip input requests',
2086
+ );
2087
+ }
1147
2088
  this.#lazyily_create_client();
1148
2089
  return this.#client?.request(method, params, 'standalone');
1149
2090
  }
@@ -1163,25 +2104,249 @@ export class McpServer {
1163
2104
  this.#notify_resources_list_changed();
1164
2105
  } else {
1165
2106
  const resource = this.#resources.get(id);
1166
- if (!resource) return;
1167
- this.#notify(
1168
- `notifications/resources/updated`,
1169
- {
2107
+ const subscription_resource =
2108
+ this.#subscription_resource_registration(id);
2109
+ if (!resource && !subscription_resource) return;
2110
+ if (resource) {
2111
+ this.#broadcast_change(`notifications/resources/updated`, {
1170
2112
  uri: id,
1171
2113
  title: resource.name,
1172
- },
1173
- 'broadcast',
1174
- );
2114
+ });
2115
+ }
2116
+ if (!resource && subscription_resource) {
2117
+ this.#broadcast_change(
2118
+ 'notifications/resources/updated',
2119
+ { uri: id },
2120
+ true,
2121
+ );
2122
+ }
1175
2123
  }
1176
2124
  }
1177
2125
 
1178
2126
  /**
1179
- * Refresh roots list from client
2127
+ * Refresh the roots list when the server has an open client session.
2128
+ *
2129
+ * This throws when handling a standalone request because roots are no
2130
+ * longer supported by protocol version `2026-07-28`.
1180
2131
  */
1181
2132
  async refreshRoots() {
1182
2133
  await this.#refresh_roots();
1183
2134
  }
1184
2135
 
2136
+ /**
2137
+ * Whether the client included its version and capabilities in this request
2138
+ * instead of opening a session first.
2139
+ */
2140
+ get #is_stateless() {
2141
+ return this.#ctx_storage.getStore()?.stateless === true;
2142
+ }
2143
+
2144
+ /**
2145
+ * Check that the client supports the requested input and that the current
2146
+ * method is allowed to ask for it.
2147
+ * @param {'elicitation' | 'sampling'} capability
2148
+ * @param {string} description Human-readable description for the session-negotiated error message
2149
+ * @param {'form' | 'url'} [elicitation_mode]
2150
+ */
2151
+ #assert_client_request_allowed(capability, description, elicitation_mode) {
2152
+ const declared_capability = this.#client_capabilities?.[capability];
2153
+ const capability_is_object =
2154
+ typeof declared_capability === 'object' &&
2155
+ declared_capability !== null &&
2156
+ !Array.isArray(declared_capability);
2157
+ const supports_elicitation_mode =
2158
+ capability_is_object &&
2159
+ (capability !== 'elicitation' ||
2160
+ (elicitation_mode === 'url'
2161
+ ? declared_capability.url !== undefined
2162
+ : Object.keys(declared_capability).length === 0 ||
2163
+ declared_capability.form !== undefined));
2164
+ const supported = supports_elicitation_mode;
2165
+ if (!supported) {
2166
+ if (this.#is_stateless) {
2167
+ throw missing_required_client_capability_error({
2168
+ [capability]:
2169
+ capability === 'elicitation'
2170
+ ? { [elicitation_mode ?? 'form']: {} }
2171
+ : {},
2172
+ });
2173
+ }
2174
+ throw new McpError(-32601, `Client doesn't support ${description}`);
2175
+ }
2176
+ if (this.#is_stateless && this.#mrtr === undefined) {
2177
+ throw new McpError(
2178
+ -32603,
2179
+ `${description} input requests are only available inside tools/call, prompts/get, and resources/read during per-request (stateless) execution`,
2180
+ );
2181
+ }
2182
+ }
2183
+
2184
+ /**
2185
+ * Work data used while the current request waits for client input.
2186
+ * It is absent when the current method cannot ask for input.
2187
+ */
2188
+ get #mrtr() {
2189
+ return this.#ctx_storage.getStore()?.mrtr;
2190
+ }
2191
+
2192
+ /**
2193
+ * Remember which handler is running so input calls can check its
2194
+ * `replayable` setting and include its name in errors.
2195
+ * @param {string} kind
2196
+ * @param {string} name
2197
+ * @param {boolean | undefined} replayable
2198
+ */
2199
+ #mark_mrtr_registration(kind, name, replayable) {
2200
+ const mrtr = this.#mrtr;
2201
+ if (mrtr === undefined) return;
2202
+ mrtr.registration = { kind, name, replayable: replayable === true };
2203
+ }
2204
+
2205
+ /**
2206
+ * Convert tmcp's retry data to and from text. Plain JSON is used by default,
2207
+ * which means the client can read and change the value.
2208
+ */
2209
+ get #request_state_codec() {
2210
+ return (
2211
+ this.#options.requestStateCodec ?? {
2212
+ /** @type {(state: unknown)=>string} */
2213
+ encode: (state) => JSON.stringify(state),
2214
+ /** @type {(encoded: string)=>unknown} */
2215
+ decode: (encoded) => JSON.parse(encoded),
2216
+ }
2217
+ );
2218
+ }
2219
+
2220
+ /**
2221
+ * Check that the handler allows tmcp to run it again after client input.
2222
+ * @param {MrtrState} mrtr
2223
+ * @param {string} description
2224
+ */
2225
+ #assert_replayable(mrtr, description) {
2226
+ if (mrtr.registration?.replayable) return;
2227
+ const registration = mrtr.registration
2228
+ ? `${mrtr.registration.kind} "${mrtr.registration.name}"`
2229
+ : 'registration';
2230
+ throw new McpError(
2231
+ -32603,
2232
+ `${registration} asked the client for ${description}, but it is not marked as replayable. The client must retry the ORIGINAL request with its answer, which starts the handler again FROM THE TOP. Work done before the input call, such as database writes, emails, or payments, may therefore happen more than once. Set \`replayable: true\` on the ${mrtr.registration?.kind ?? 'tool/prompt/resource'} definition only when that work is safe to repeat or is delayed until after all input is available. This is a tmcp safety check, not an MCP requirement.`,
2233
+ );
2234
+ }
2235
+
2236
+ /**
2237
+ * Use the provided input name, or assign the next number (`"1"`, `"2"`,
2238
+ * and so on). Numbering starts again each time the client retries.
2239
+ * @param {MrtrState} mrtr
2240
+ * @param {string | undefined} explicit_key
2241
+ */
2242
+ #next_input_key(mrtr, explicit_key) {
2243
+ if (explicit_key != null) {
2244
+ if (mrtr.used_keys.has(explicit_key)) {
2245
+ const error = new McpError(
2246
+ -32603,
2247
+ `Duplicate input request key "${explicit_key}": each elicitation()/message() call within one request attempt must use a distinct key`,
2248
+ );
2249
+ mrtr.input_error = error;
2250
+ throw error;
2251
+ }
2252
+ mrtr.used_keys.add(explicit_key);
2253
+ return explicit_key;
2254
+ }
2255
+ let key;
2256
+ do {
2257
+ mrtr.ordinal += 1;
2258
+ key = String(mrtr.ordinal);
2259
+ } while (mrtr.used_keys.has(key));
2260
+ mrtr.used_keys.add(key);
2261
+ return key;
2262
+ }
2263
+
2264
+ /**
2265
+ * Let a handler recover from an invalid client answer by asking again with
2266
+ * the same key instead of consuming the same invalid value repeatedly.
2267
+ * @param {MrtrState} mrtr
2268
+ * @param {string} key
2269
+ */
2270
+ #release_input_response(mrtr, key) {
2271
+ mrtr.used_keys.delete(key);
2272
+ if (mrtr.input_responses !== undefined) {
2273
+ delete mrtr.input_responses[key];
2274
+ }
2275
+ }
2276
+
2277
+ /**
2278
+ * Save data that the handler will need when the client retries this
2279
+ * request. tmcp turns it into text, sends it to the client, and restores it
2280
+ * as `server.ctx.requestState` on the retry.
2281
+ * Passing `undefined` clears previously set state.
2282
+ *
2283
+ * This does nothing when the server has an open client session or when the
2284
+ * request finishes without asking for input.
2285
+ *
2286
+ * SECURITY: the default JSON converter lets the client read and change this
2287
+ * data. Do not put secrets in it or use it for authorization. Configure a
2288
+ * protected `requestStateCodec` if the server must detect changes.
2289
+ * @param {unknown} state
2290
+ */
2291
+ setRequestState(state) {
2292
+ const mrtr = this.#mrtr;
2293
+ if (mrtr === undefined) return;
2294
+ mrtr.outgoing_state = state;
2295
+ }
2296
+
2297
+ /**
2298
+ * Build and validate an elicitation request sent to the client.
2299
+ * @template {StandardSchema extends undefined ? never : StandardSchema} TSchema
2300
+ * @param {string} message
2301
+ * @param {TSchema | string} schema_or_url
2302
+ */
2303
+ async #create_elicitation_request(message, schema_or_url) {
2304
+ const request =
2305
+ typeof schema_or_url === 'string'
2306
+ ? {
2307
+ method: /** @type {const} */ ('elicitation/create'),
2308
+ params: {
2309
+ mode: /** @type {const} */ ('url'),
2310
+ message,
2311
+ url: schema_or_url,
2312
+ },
2313
+ }
2314
+ : {
2315
+ method: /** @type {const} */ ('elicitation/create'),
2316
+ params: {
2317
+ message,
2318
+ requestedSchema:
2319
+ await this.#options.adapter?.toJsonSchema(
2320
+ schema_or_url,
2321
+ ),
2322
+ },
2323
+ };
2324
+ const parsed = v.safeParse(ElicitRequestSchema, request);
2325
+ if (!parsed.success) {
2326
+ if (typeof schema_or_url === 'string') {
2327
+ throw new McpError(
2328
+ -32602,
2329
+ `URL ${schema_or_url} is not a valid URL`,
2330
+ );
2331
+ }
2332
+ throw new McpError(
2333
+ -32603,
2334
+ `Invalid elicitation schema: form elicitation requires a flat object containing only supported primitive fields (${JSON.stringify(parsed.issues)})`,
2335
+ );
2336
+ }
2337
+ return parsed.output;
2338
+ }
2339
+
2340
+ /**
2341
+ * Ask the user to complete an interaction at a URL. The client will open
2342
+ * the URL out of band and return the user's action without form content.
2343
+ *
2344
+ * @overload
2345
+ * @param {string} message
2346
+ * @param {string} url
2347
+ * @param {{ key?: string }} [options] `key` names this question so tmcp can match its answer on a retry.
2348
+ * @returns {Promise<Omit<ElicitResult, 'content'>>}
2349
+ */
1185
2350
  /**
1186
2351
  * Emit an elicitation request to the client. Elicitations are used to ask the user for input in a structured way, the client will show a UI to the user to fill the input.
1187
2352
  * The schema should be a valid Standard Schema V1 schema and should be an Object with the properties you need.
@@ -1189,26 +2354,131 @@ export class McpServer {
1189
2354
  *
1190
2355
  * If the client doesn't support elicitation, it will throw an error.
1191
2356
  *
2357
+ * When there is no open client session, tmcp returns the question to the
2358
+ * client and asks it to retry the original request with the answer. The
2359
+ * handler then starts again from the beginning, so its definition must set
2360
+ * `replayable: true`. Always await this call. If a surrounding `catch`
2361
+ * handles errors, use `isInputRequired()` and rethrow tmcp's private error.
2362
+ *
1192
2363
  * @template {StandardSchema extends undefined ? never : StandardSchema} TSchema
2364
+ * @overload
1193
2365
  * @param {string} message
1194
2366
  * @param {TSchema} schema
2367
+ * @param {{ key?: string }} [options] `key` names this question so tmcp can match its answer on a retry. By default tmcp uses `"1"`, `"2"`, and so on. Set a name when the handler may ask different questions on different runs. When mixing named and numbered questions, use non-numeric names.
1195
2368
  * @returns {Promise<ElicitResult & { content?: StandardSchemaV1.InferOutput<TSchema> }>}
1196
2369
  */
1197
- async elicitation(message, schema) {
1198
- if (!this.#client_capabilities?.elicitation)
1199
- throw new McpError(-32601, "Client doesn't support elicitation");
2370
+ /**
2371
+ * @template {StandardSchema extends undefined ? never : StandardSchema} TSchema
2372
+ * @param {string} message
2373
+ * @param {TSchema | string} schema_or_url
2374
+ * @param {{ key?: string }} [options]
2375
+ * @returns {Promise<Omit<ElicitResult, 'content'> | (ElicitResult & { content?: StandardSchemaV1.InferOutput<TSchema> })>}
2376
+ */
2377
+ async elicitation(message, schema_or_url, options = {}) {
2378
+ const mode = typeof schema_or_url === 'string' ? 'url' : 'form';
2379
+ this.#assert_client_request_allowed(
2380
+ 'elicitation',
2381
+ `${mode} mode elicitation`,
2382
+ mode,
2383
+ );
2384
+
2385
+ const mrtr = this.#mrtr;
2386
+ if (mrtr !== undefined) {
2387
+ this.#assert_replayable(mrtr, 'elicitation');
2388
+ const key = this.#next_input_key(mrtr, options.key);
2389
+ const has_response =
2390
+ mrtr.input_responses !== undefined &&
2391
+ Object.hasOwn(mrtr.input_responses, key);
2392
+ if (has_response) {
2393
+ try {
2394
+ const provided = mrtr.input_responses?.[key];
2395
+ const parsed = v.safeParse(ElicitResultSchema, provided);
2396
+ if (!parsed.success) {
2397
+ throw new McpError(
2398
+ -32602,
2399
+ `Invalid input response for key "${key}": expected an elicitation result ({ action, content? })`,
2400
+ );
2401
+ }
2402
+ const result =
2403
+ typeof schema_or_url !== 'string'
2404
+ ? await this.#validate_elicit_result(
2405
+ parsed.output,
2406
+ schema_or_url,
2407
+ -32602,
2408
+ )
2409
+ : this.#without_elicit_content(parsed.output);
2410
+ mrtr.consumed_responses.set(
2411
+ key,
2412
+ typeof schema_or_url === 'string'
2413
+ ? result
2414
+ : parsed.output,
2415
+ );
2416
+ return result;
2417
+ } catch (error) {
2418
+ this.#release_input_response(mrtr, key);
2419
+ throw error;
2420
+ }
2421
+ }
2422
+ const pending_request = this.#create_elicitation_request(
2423
+ message,
2424
+ schema_or_url,
2425
+ );
2426
+ mrtr.pending.set(key, pending_request);
2427
+ try {
2428
+ await pending_request;
2429
+ } catch (error) {
2430
+ if (mrtr.pending.get(key) === pending_request) {
2431
+ mrtr.pending.delete(key);
2432
+ mrtr.used_keys.delete(key);
2433
+ }
2434
+ throw error;
2435
+ }
2436
+ throw mrtr.signal;
2437
+ }
1200
2438
 
1201
2439
  this.#lazyily_create_client();
2440
+ const request = await this.#create_elicitation_request(
2441
+ message,
2442
+ schema_or_url,
2443
+ );
1202
2444
  const result = await this.#client?.request(
1203
- 'elicitation/create',
1204
- {
1205
- message,
1206
- requestedSchema:
1207
- await this.#options.adapter?.toJsonSchema(schema),
1208
- },
2445
+ request.method,
2446
+ request.params,
1209
2447
  'standalone',
1210
2448
  );
1211
2449
  const elicit_result = v.parse(ElicitResultSchema, result);
2450
+ return typeof schema_or_url !== 'string'
2451
+ ? this.#validate_elicit_result(elicit_result, schema_or_url)
2452
+ : this.#without_elicit_content(elicit_result);
2453
+ }
2454
+
2455
+ /**
2456
+ * URL elicitation is out of band, so form content must never reach the
2457
+ * handler or be carried into a later stateless retry.
2458
+ * @param {ElicitResult} elicit_result
2459
+ * @returns {Omit<ElicitResult, 'content'>}
2460
+ */
2461
+ #without_elicit_content(elicit_result) {
2462
+ const result = { ...elicit_result };
2463
+ delete result.content;
2464
+ return result;
2465
+ }
2466
+
2467
+ /**
2468
+ * Validate the `content` of an elicit result against the schema the
2469
+ * input was requested with.
2470
+ * @template {StandardSchema extends undefined ? never : StandardSchema} TSchema
2471
+ * @param {ElicitResult} elicit_result
2472
+ * @param {TSchema} schema
2473
+ * @param {number} [invalid_content_code]
2474
+ * @returns {Promise<ElicitResult & { content?: StandardSchemaV1.InferOutput<TSchema> }>}
2475
+ */
2476
+ async #validate_elicit_result(
2477
+ elicit_result,
2478
+ schema,
2479
+ invalid_content_code = -32603,
2480
+ ) {
2481
+ if (elicit_result.action !== 'accept') return elicit_result;
1212
2482
  let validated_result = schema['~standard'].validate(
1213
2483
  elicit_result.content,
1214
2484
  );
@@ -1216,7 +2486,7 @@ export class McpServer {
1216
2486
  validated_result = await validated_result;
1217
2487
  if (validated_result.issues) {
1218
2488
  throw new McpError(
1219
- -32603,
2489
+ invalid_content_code,
1220
2490
  `Invalid elicitation result: ${JSON.stringify(validated_result.issues)}`,
1221
2491
  );
1222
2492
  }
@@ -1224,15 +2494,19 @@ export class McpServer {
1224
2494
  }
1225
2495
 
1226
2496
  /**
1227
- * Request language model sampling from the client
2497
+ * Request language model sampling from the client.
2498
+ *
2499
+ * When there is no open client session, tmcp returns the sampling request
2500
+ * to the client and asks it to retry the original request with the answer.
2501
+ * The handler then starts again from the beginning, so its definition must
2502
+ * set `replayable: true`. Always await this call. If a surrounding `catch`
2503
+ * handles errors, use `isInputRequired()` and rethrow tmcp's private error.
1228
2504
  * @param {CreateMessageRequestParams} request
2505
+ * @param {{ key?: string }} [options] `key` names this request so tmcp can match its answer on a retry. By default tmcp uses `"1"`, `"2"`, and so on. Set a name when the handler may make different requests on different runs. When mixing named and numbered requests, use non-numeric names.
1229
2506
  * @returns {Promise<CreateMessageResult>}
1230
2507
  */
1231
- async message(request) {
1232
- if (!this.#client_capabilities?.sampling)
1233
- throw new McpError(-32601, "Client doesn't support sampling");
1234
-
1235
- this.#lazyily_create_client();
2508
+ async message(request, options = {}) {
2509
+ this.#assert_client_request_allowed('sampling', 'sampling');
1236
2510
 
1237
2511
  // Validate the request
1238
2512
  const validated_request = v.parse(
@@ -1240,6 +2514,45 @@ export class McpServer {
1240
2514
  request,
1241
2515
  );
1242
2516
 
2517
+ const mrtr = this.#mrtr;
2518
+ if (mrtr !== undefined) {
2519
+ this.#assert_replayable(mrtr, 'sampling');
2520
+ const key = this.#next_input_key(mrtr, options.key);
2521
+ const has_response =
2522
+ mrtr.input_responses !== undefined &&
2523
+ Object.hasOwn(mrtr.input_responses, key);
2524
+ if (has_response) {
2525
+ try {
2526
+ const provided = mrtr.input_responses?.[key];
2527
+ const parsed = v.safeParse(
2528
+ CreateMessageResultSchema,
2529
+ provided,
2530
+ );
2531
+ if (!parsed.success) {
2532
+ throw new McpError(
2533
+ -32602,
2534
+ `Invalid input response for key "${key}": expected a sampling result (CreateMessageResult)`,
2535
+ );
2536
+ }
2537
+ mrtr.consumed_responses.set(key, parsed.output);
2538
+ return parsed.output;
2539
+ } catch (error) {
2540
+ this.#release_input_response(mrtr, key);
2541
+ throw error;
2542
+ }
2543
+ }
2544
+ mrtr.pending.set(
2545
+ key,
2546
+ Promise.resolve({
2547
+ method: 'sampling/createMessage',
2548
+ params: validated_request,
2549
+ }),
2550
+ );
2551
+ throw mrtr.signal;
2552
+ }
2553
+
2554
+ this.#lazyily_create_client();
2555
+
1243
2556
  // Make the request to the client
1244
2557
  const response = await this.#client?.request(
1245
2558
  'sampling/createMessage',
@@ -1284,10 +2597,12 @@ export class McpServer {
1284
2597
  );
1285
2598
  }
1286
2599
 
1287
- const current_session_level =
1288
- this.#ctx_storage.getStore()?.sessionInfo?.logLevel ??
1289
- this.#options.logging?.default ??
1290
- 'info';
2600
+ const store = this.#ctx_storage.getStore();
2601
+ const current_session_level = store?.stateless
2602
+ ? store.sessionInfo?.logLevel
2603
+ : (store?.sessionInfo?.logLevel ??
2604
+ this.#options.logging?.default ??
2605
+ 'info');
1291
2606
 
1292
2607
  if (
1293
2608
  current_session_level &&