@nitrostack/core 1.0.16 → 1.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/dist/core/app-decorator.d.ts +5 -0
  2. package/dist/core/app-decorator.d.ts.map +1 -1
  3. package/dist/core/app-decorator.js +2 -1
  4. package/dist/core/app-decorator.js.map +1 -1
  5. package/dist/core/builders.d.ts.map +1 -1
  6. package/dist/core/builders.js +2 -0
  7. package/dist/core/builders.js.map +1 -1
  8. package/dist/core/decorators.d.ts +11 -0
  9. package/dist/core/decorators.d.ts.map +1 -1
  10. package/dist/core/decorators.js.map +1 -1
  11. package/dist/core/health/health-checks.resource.d.ts +4 -3
  12. package/dist/core/health/health-checks.resource.d.ts.map +1 -1
  13. package/dist/core/health/health-checks.resource.js +35 -8
  14. package/dist/core/health/health-checks.resource.js.map +1 -1
  15. package/dist/core/health/health.interface.d.ts +40 -0
  16. package/dist/core/health/health.interface.d.ts.map +1 -0
  17. package/dist/core/health/health.interface.js +2 -0
  18. package/dist/core/health/health.interface.js.map +1 -0
  19. package/dist/core/index.d.ts +11 -2
  20. package/dist/core/index.d.ts.map +1 -1
  21. package/dist/core/index.js +9 -1
  22. package/dist/core/index.js.map +1 -1
  23. package/dist/core/interceptors/data-spillover.interceptor.d.ts +30 -0
  24. package/dist/core/interceptors/data-spillover.interceptor.d.ts.map +1 -0
  25. package/dist/core/interceptors/data-spillover.interceptor.js +174 -0
  26. package/dist/core/interceptors/data-spillover.interceptor.js.map +1 -0
  27. package/dist/core/interceptors/interceptor.decorator.d.ts +17 -8
  28. package/dist/core/interceptors/interceptor.decorator.d.ts.map +1 -1
  29. package/dist/core/interceptors/interceptor.decorator.js +36 -11
  30. package/dist/core/interceptors/interceptor.decorator.js.map +1 -1
  31. package/dist/core/interceptors/interceptor.interface.d.ts +4 -0
  32. package/dist/core/interceptors/interceptor.interface.d.ts.map +1 -1
  33. package/dist/core/interceptors/spillover/fs-spillover.store.d.ts +54 -0
  34. package/dist/core/interceptors/spillover/fs-spillover.store.d.ts.map +1 -0
  35. package/dist/core/interceptors/spillover/fs-spillover.store.js +302 -0
  36. package/dist/core/interceptors/spillover/fs-spillover.store.js.map +1 -0
  37. package/dist/core/interceptors/spillover/index.d.ts +6 -0
  38. package/dist/core/interceptors/spillover/index.d.ts.map +1 -0
  39. package/dist/core/interceptors/spillover/index.js +6 -0
  40. package/dist/core/interceptors/spillover/index.js.map +1 -0
  41. package/dist/core/interceptors/spillover/memory-spillover.store.d.ts +25 -0
  42. package/dist/core/interceptors/spillover/memory-spillover.store.d.ts.map +1 -0
  43. package/dist/core/interceptors/spillover/memory-spillover.store.js +115 -0
  44. package/dist/core/interceptors/spillover/memory-spillover.store.js.map +1 -0
  45. package/dist/core/interceptors/spillover/preview-generator.d.ts +7 -0
  46. package/dist/core/interceptors/spillover/preview-generator.d.ts.map +1 -0
  47. package/dist/core/interceptors/spillover/preview-generator.js +79 -0
  48. package/dist/core/interceptors/spillover/preview-generator.js.map +1 -0
  49. package/dist/core/interceptors/spillover/spillover-store.interface.d.ts +20 -0
  50. package/dist/core/interceptors/spillover/spillover-store.interface.d.ts.map +1 -0
  51. package/dist/core/interceptors/spillover/spillover-store.interface.js +2 -0
  52. package/dist/core/interceptors/spillover/spillover-store.interface.js.map +1 -0
  53. package/dist/core/interceptors/spillover/types.d.ts +71 -0
  54. package/dist/core/interceptors/spillover/types.d.ts.map +1 -0
  55. package/dist/core/interceptors/spillover/types.js +12 -0
  56. package/dist/core/interceptors/spillover/types.js.map +1 -0
  57. package/dist/core/protocol/adapter.d.ts +7 -1
  58. package/dist/core/protocol/adapter.d.ts.map +1 -1
  59. package/dist/core/protocol/modern-v2.adapter.d.ts +95 -2
  60. package/dist/core/protocol/modern-v2.adapter.d.ts.map +1 -1
  61. package/dist/core/protocol/modern-v2.adapter.js +688 -157
  62. package/dist/core/protocol/modern-v2.adapter.js.map +1 -1
  63. package/dist/core/server.d.ts +104 -4
  64. package/dist/core/server.d.ts.map +1 -1
  65. package/dist/core/server.js +450 -44
  66. package/dist/core/server.js.map +1 -1
  67. package/dist/core/task.d.ts.map +1 -1
  68. package/dist/core/task.js +8 -7
  69. package/dist/core/task.js.map +1 -1
  70. package/dist/core/tool.d.ts +12 -3
  71. package/dist/core/tool.d.ts.map +1 -1
  72. package/dist/core/tool.js +21 -3
  73. package/dist/core/tool.js.map +1 -1
  74. package/dist/core/transforms/catalog.transform.d.ts +40 -0
  75. package/dist/core/transforms/catalog.transform.d.ts.map +1 -0
  76. package/dist/core/transforms/catalog.transform.js +52 -0
  77. package/dist/core/transforms/catalog.transform.js.map +1 -0
  78. package/dist/core/transforms/code-mode/code-mode.transform.d.ts +75 -0
  79. package/dist/core/transforms/code-mode/code-mode.transform.d.ts.map +1 -0
  80. package/dist/core/transforms/code-mode/code-mode.transform.js +226 -0
  81. package/dist/core/transforms/code-mode/code-mode.transform.js.map +1 -0
  82. package/dist/core/transforms/code-mode/destructive-guard.d.ts +7 -0
  83. package/dist/core/transforms/code-mode/destructive-guard.d.ts.map +1 -0
  84. package/dist/core/transforms/code-mode/destructive-guard.js +18 -0
  85. package/dist/core/transforms/code-mode/destructive-guard.js.map +1 -0
  86. package/dist/core/transforms/code-mode/guest-evaluator.d.ts +17 -0
  87. package/dist/core/transforms/code-mode/guest-evaluator.d.ts.map +1 -0
  88. package/dist/core/transforms/code-mode/guest-evaluator.js +277 -0
  89. package/dist/core/transforms/code-mode/guest-evaluator.js.map +1 -0
  90. package/dist/core/transforms/code-mode/index.d.ts +11 -0
  91. package/dist/core/transforms/code-mode/index.d.ts.map +1 -0
  92. package/dist/core/transforms/code-mode/index.js +11 -0
  93. package/dist/core/transforms/code-mode/index.js.map +1 -0
  94. package/dist/core/transforms/code-mode/ipc-messages.d.ts +31 -0
  95. package/dist/core/transforms/code-mode/ipc-messages.d.ts.map +1 -0
  96. package/dist/core/transforms/code-mode/ipc-messages.js +2 -0
  97. package/dist/core/transforms/code-mode/ipc-messages.js.map +1 -0
  98. package/dist/core/transforms/code-mode/legible-error.d.ts +8 -0
  99. package/dist/core/transforms/code-mode/legible-error.d.ts.map +1 -0
  100. package/dist/core/transforms/code-mode/legible-error.js +17 -0
  101. package/dist/core/transforms/code-mode/legible-error.js.map +1 -0
  102. package/dist/core/transforms/code-mode/quickjs-wasm.provider.d.ts +25 -0
  103. package/dist/core/transforms/code-mode/quickjs-wasm.provider.d.ts.map +1 -0
  104. package/dist/core/transforms/code-mode/quickjs-wasm.provider.js +31 -0
  105. package/dist/core/transforms/code-mode/quickjs-wasm.provider.js.map +1 -0
  106. package/dist/core/transforms/code-mode/sandbox.interface.d.ts +10 -0
  107. package/dist/core/transforms/code-mode/sandbox.interface.d.ts.map +1 -0
  108. package/dist/core/transforms/code-mode/sandbox.interface.js +2 -0
  109. package/dist/core/transforms/code-mode/sandbox.interface.js.map +1 -0
  110. package/dist/core/transforms/code-mode/sandbox.worker.d.ts +2 -0
  111. package/dist/core/transforms/code-mode/sandbox.worker.d.ts.map +1 -0
  112. package/dist/core/transforms/code-mode/sandbox.worker.js +69 -0
  113. package/dist/core/transforms/code-mode/sandbox.worker.js.map +1 -0
  114. package/dist/core/transforms/code-mode/synthetic-tools.d.ts +23 -0
  115. package/dist/core/transforms/code-mode/synthetic-tools.d.ts.map +1 -0
  116. package/dist/core/transforms/code-mode/synthetic-tools.js +112 -0
  117. package/dist/core/transforms/code-mode/synthetic-tools.js.map +1 -0
  118. package/dist/core/transforms/code-mode/types.d.ts +32 -0
  119. package/dist/core/transforms/code-mode/types.d.ts.map +1 -0
  120. package/dist/core/transforms/code-mode/types.js +2 -0
  121. package/dist/core/transforms/code-mode/types.js.map +1 -0
  122. package/dist/core/transforms/code-mode/worker-pool.d.ts +71 -0
  123. package/dist/core/transforms/code-mode/worker-pool.d.ts.map +1 -0
  124. package/dist/core/transforms/code-mode/worker-pool.js +435 -0
  125. package/dist/core/transforms/code-mode/worker-pool.js.map +1 -0
  126. package/dist/core/transforms/index.d.ts +6 -0
  127. package/dist/core/transforms/index.d.ts.map +1 -0
  128. package/dist/core/transforms/index.js +6 -0
  129. package/dist/core/transforms/index.js.map +1 -0
  130. package/dist/core/transforms/search/base-search.transform.d.ts +59 -0
  131. package/dist/core/transforms/search/base-search.transform.d.ts.map +1 -0
  132. package/dist/core/transforms/search/base-search.transform.js +166 -0
  133. package/dist/core/transforms/search/base-search.transform.js.map +1 -0
  134. package/dist/core/transforms/search/bm25-search.transform.d.ts +27 -0
  135. package/dist/core/transforms/search/bm25-search.transform.d.ts.map +1 -0
  136. package/dist/core/transforms/search/bm25-search.transform.js +37 -0
  137. package/dist/core/transforms/search/bm25-search.transform.js.map +1 -0
  138. package/dist/core/transforms/search/bm25.engine.d.ts +46 -0
  139. package/dist/core/transforms/search/bm25.engine.d.ts.map +1 -0
  140. package/dist/core/transforms/search/bm25.engine.js +160 -0
  141. package/dist/core/transforms/search/bm25.engine.js.map +1 -0
  142. package/dist/core/transforms/search/index.d.ts +9 -0
  143. package/dist/core/transforms/search/index.d.ts.map +1 -0
  144. package/dist/core/transforms/search/index.js +9 -0
  145. package/dist/core/transforms/search/index.js.map +1 -0
  146. package/dist/core/transforms/search/regex-match.worker.d.ts +2 -0
  147. package/dist/core/transforms/search/regex-match.worker.d.ts.map +1 -0
  148. package/dist/core/transforms/search/regex-match.worker.js +19 -0
  149. package/dist/core/transforms/search/regex-match.worker.js.map +1 -0
  150. package/dist/core/transforms/search/regex-search.transform.d.ts +41 -0
  151. package/dist/core/transforms/search/regex-search.transform.d.ts.map +1 -0
  152. package/dist/core/transforms/search/regex-search.transform.js +237 -0
  153. package/dist/core/transforms/search/regex-search.transform.js.map +1 -0
  154. package/dist/core/transforms/search/synthetic-tools.d.ts +11 -0
  155. package/dist/core/transforms/search/synthetic-tools.d.ts.map +1 -0
  156. package/dist/core/transforms/search/synthetic-tools.js +76 -0
  157. package/dist/core/transforms/search/synthetic-tools.js.map +1 -0
  158. package/dist/core/transforms/search/tokenizer.d.ts +11 -0
  159. package/dist/core/transforms/search/tokenizer.d.ts.map +1 -0
  160. package/dist/core/transforms/search/tokenizer.js +38 -0
  161. package/dist/core/transforms/search/tokenizer.js.map +1 -0
  162. package/dist/core/transforms/search/tool-serializer.d.ts +6 -0
  163. package/dist/core/transforms/search/tool-serializer.d.ts.map +1 -0
  164. package/dist/core/transforms/search/tool-serializer.js +41 -0
  165. package/dist/core/transforms/search/tool-serializer.js.map +1 -0
  166. package/dist/core/transforms/search/types.d.ts +36 -0
  167. package/dist/core/transforms/search/types.d.ts.map +1 -0
  168. package/dist/core/transforms/search/types.js +2 -0
  169. package/dist/core/transforms/search/types.js.map +1 -0
  170. package/dist/core/transforms/tool-cache-key.d.ts +15 -0
  171. package/dist/core/transforms/tool-cache-key.d.ts.map +1 -0
  172. package/dist/core/transforms/tool-cache-key.js +61 -0
  173. package/dist/core/transforms/tool-cache-key.js.map +1 -0
  174. package/dist/core/transforms/transform.interface.d.ts +34 -0
  175. package/dist/core/transforms/transform.interface.d.ts.map +1 -0
  176. package/dist/core/transforms/transform.interface.js +2 -0
  177. package/dist/core/transforms/transform.interface.js.map +1 -0
  178. package/dist/core/transforms/validate-tool-arguments.d.ts +10 -0
  179. package/dist/core/transforms/validate-tool-arguments.d.ts.map +1 -0
  180. package/dist/core/transforms/validate-tool-arguments.js +62 -0
  181. package/dist/core/transforms/validate-tool-arguments.js.map +1 -0
  182. package/dist/core/transforms/visibility/index.d.ts +3 -0
  183. package/dist/core/transforms/visibility/index.d.ts.map +1 -0
  184. package/dist/core/transforms/visibility/index.js +3 -0
  185. package/dist/core/transforms/visibility/index.js.map +1 -0
  186. package/dist/core/transforms/visibility/session-store.d.ts +132 -0
  187. package/dist/core/transforms/visibility/session-store.d.ts.map +1 -0
  188. package/dist/core/transforms/visibility/session-store.js +330 -0
  189. package/dist/core/transforms/visibility/session-store.js.map +1 -0
  190. package/dist/core/transforms/visibility/visibility.transform.d.ts +42 -0
  191. package/dist/core/transforms/visibility/visibility.transform.d.ts.map +1 -0
  192. package/dist/core/transforms/visibility/visibility.transform.js +109 -0
  193. package/dist/core/transforms/visibility/visibility.transform.js.map +1 -0
  194. package/dist/core/types.d.ts +60 -0
  195. package/dist/core/types.d.ts.map +1 -1
  196. package/dist/testing/index.d.ts.map +1 -1
  197. package/dist/testing/index.js +3 -0
  198. package/dist/testing/index.js.map +1 -1
  199. package/package.json +9 -6
  200. package/dist/core/widget-bundle-paths.d.ts +0 -5
  201. package/dist/core/widget-bundle-paths.d.ts.map +0 -1
  202. package/dist/core/widget-bundle-paths.js +0 -25
  203. package/dist/core/widget-bundle-paths.js.map +0 -1
  204. package/dist/core/widget-html-mime.d.ts +0 -7
  205. package/dist/core/widget-html-mime.d.ts.map +0 -1
  206. package/dist/core/widget-html-mime.js +0 -7
  207. package/dist/core/widget-html-mime.js.map +0 -1
  208. package/dist/core/widget-mime.d.ts +0 -29
  209. package/dist/core/widget-mime.d.ts.map +0 -1
  210. package/dist/core/widget-mime.js +0 -41
  211. package/dist/core/widget-mime.js.map +0 -1
@@ -30,6 +30,12 @@ import { mapToJsonRpcError } from './features/errors.js';
30
30
  import { isInputRequired } from './features/mrtr.js';
31
31
  import { isMcpAppMode, isOpenAiMode } from '../app-mode.js';
32
32
  import { TaskContext, TaskAugmentationRequiredError } from '../task.js';
33
+ function sessionRequiredError() {
34
+ const error = new Error('Session required');
35
+ error.name = 'SessionRequiredError';
36
+ error.code = -32600;
37
+ return error;
38
+ }
33
39
  /** Meta-key constants (SEP-2575 / SEP-414) used to read the request envelope. */
34
40
  const META = {
35
41
  PROTOCOL_VERSION: 'io.modelcontextprotocol/protocolVersion',
@@ -42,8 +48,23 @@ export class ModernProtocolAdapter {
42
48
  era = 'modern';
43
49
  handler;
44
50
  stdioHandle;
51
+ /** Process-local session for the single stdio peer. Not taken from a client header. */
52
+ stdioSessionId;
53
+ /** Tool registrations on the pinned stdio server, kept to resync after visibility changes. */
54
+ stdioCatalog;
55
+ stdioResync = Promise.resolve();
45
56
  serverSdkPromise;
46
57
  taskManager;
58
+ /**
59
+ * HTTP session ids minted by this process. Client-supplied ids are not members.
60
+ * The first verified subject to use an id owns it.
61
+ */
62
+ issuedSessions = new Map();
63
+ /** Method the HTTP gate already classified for this request. The SDK consumes the body before the factory runs. */
64
+ gatedMethods = new WeakMap();
65
+ static ISSUED_SESSION_TTL_MS = 30 * 60 * 1000;
66
+ static MAX_ISSUED_SESSIONS = 1000;
67
+ lastSessionCapWarning = 0;
47
68
  constructor(registry, options = { legacyMode: 'reject' }) {
48
69
  this.registry = registry;
49
70
  this.options = options;
@@ -58,91 +79,338 @@ export class ModernProtocolAdapter {
58
79
  // ==========================================================================
59
80
  // Server construction (called per request by the SDK factory)
60
81
  // ==========================================================================
61
- async buildServer() {
82
+ async buildServer(factoryCtx, source = 'http') {
62
83
  const sdk = await this.loadServerSdk();
63
84
  const config = this.registry.config;
64
85
  const extensions = this.advertisedExtensions();
65
86
  const serverOptions = {
66
87
  cacheHints: this.buildServerCacheHints(),
67
88
  };
89
+ // The SDK installs tools/list only after a tool is registered or when the
90
+ // tools capability is set. A session that has hidden every tool must still
91
+ // answer tools/list with an empty catalog.
92
+ const capabilities = {};
68
93
  if (Object.keys(extensions).length > 0) {
69
- // Advertise the SEP-2133 extensions map on server/discover capabilities.
70
- serverOptions.capabilities = { extensions };
94
+ capabilities.extensions = extensions;
95
+ }
96
+ if (this.registry.getTools().size > 0) {
97
+ capabilities.tools = {};
98
+ }
99
+ if (Object.keys(capabilities).length > 0) {
100
+ serverOptions.capabilities = capabilities;
71
101
  }
72
102
  const server = new sdk.McpServer({ name: config.name, version: config.version }, serverOptions);
73
- await this.registerTools(server, sdk);
74
- await this.registerResources(server, sdk);
103
+ // The SDK calls this factory once per HTTP request, with the request on
104
+ // `requestInfo`. Catalog shaping (session visibility) and spillover reads
105
+ // both need that session; a context-free list is the stateless catalog.
106
+ // `source` selects the stdio peer id. Hardcoding `http` made stdio list
107
+ // with no session and threw Session required when visibility was on.
108
+ const requestContext = await this.contextFromFactory(factoryCtx, source);
109
+ const handles = await this.registerTools(server, sdk, requestContext);
110
+ if (source === 'stdio') {
111
+ this.stdioCatalog = { server, sdk, handles };
112
+ }
113
+ await this.registerResources(server, sdk, requestContext);
75
114
  await this.registerPrompts(server, sdk);
76
115
  return server;
77
116
  }
117
+ /**
118
+ * Session and auth from the per-request factory context.
119
+ * `createMcpHandler` supplies `requestInfo` (the HTTP request) and optional `authInfo`.
120
+ */
121
+ async contextFromFactory(factoryCtx, source = 'http') {
122
+ const request = factoryCtx?.requestInfo;
123
+ const headerSession = request?.headers?.get?.('mcp-session-id') ||
124
+ request?.headers?.get?.('Mcp-Session-Id') ||
125
+ undefined;
126
+ const headerMethod = request?.headers?.get?.('mcp-method') || request?.headers?.get?.('Mcp-Method') || undefined;
127
+ // The legacy fallback builds the server from a cloned request, so the
128
+ // gate's WeakMap entry (on the original) is missing. The clone's body is
129
+ // still readable here; the transport reads it afterwards.
130
+ let method = (request ? this.gatedMethods.get(request) : undefined) ?? headerMethod;
131
+ if (!method && source === 'http' && request?.clone) {
132
+ try {
133
+ const body = (await request.clone().json());
134
+ method = typeof body?.method === 'string' ? body.method : undefined;
135
+ }
136
+ catch {
137
+ method = undefined;
138
+ }
139
+ }
140
+ // initialize, ping, and server/discover run before a session exists.
141
+ // A client-supplied id on those methods is not an isolation key.
142
+ // Stdio ignores a caller-supplied Mcp-Session-Id. That header is the HTTP
143
+ // isolation key, and accepting it here would let the local peer read it.
144
+ const sessionId = source === 'stdio'
145
+ ? this.stdioSessionId
146
+ : this.isSessionFreeMethod(method)
147
+ ? undefined
148
+ : this.httpSessionOrThrow(headerSession);
149
+ if (source !== 'stdio') {
150
+ this.bindIssuedSubject(sessionId, factoryCtx?.authInfo);
151
+ }
152
+ if (!factoryCtx && !sessionId)
153
+ return undefined;
154
+ return this.executionContextFromRequest({
155
+ sessionId,
156
+ authInfo: factoryCtx?.authInfo,
157
+ protocolVersion: MODERN_PROTOCOL_VERSION,
158
+ });
159
+ }
160
+ visibilityRequiresSession() {
161
+ return this.registry.hasSessionVisibility();
162
+ }
163
+ /** Methods that proceed without a server-issued session. */
164
+ isSessionFreeMethod(method) {
165
+ return method === 'initialize' || method === 'ping' || method === 'server/discover';
166
+ }
167
+ /**
168
+ * Remember a session id this process minted.
169
+ * Tests use this to present the same id a successful `initialize` would return.
170
+ * An id the client invented is not accepted by {@link httpSessionOrThrow}.
171
+ */
172
+ issueSession(id = crypto.randomUUID()) {
173
+ this.sweepIssuedSessions();
174
+ if (!this.issuedSessions.has(id) && this.issuedSessions.size >= ModernProtocolAdapter.MAX_ISSUED_SESSIONS) {
175
+ // Refusing here would let anonymous `initialize` calls lock out every new client.
176
+ this.evictIssuedSession();
177
+ }
178
+ const now = Date.now();
179
+ const existing = this.issuedSessions.get(id);
180
+ this.issuedSessions.set(id, {
181
+ createdAt: existing?.createdAt ?? now,
182
+ lastActive: now,
183
+ });
184
+ return id;
185
+ }
186
+ /** True when `id` is unexpired and was minted here. Refreshes its idle timer. */
187
+ touchIssuedSession(id) {
188
+ const entry = this.issuedSessions.get(id);
189
+ if (!entry)
190
+ return false;
191
+ if (Date.now() - entry.lastActive > ModernProtocolAdapter.ISSUED_SESSION_TTL_MS) {
192
+ this.issuedSessions.delete(id);
193
+ return false;
194
+ }
195
+ entry.lastActive = Date.now();
196
+ return true;
197
+ }
198
+ /** Drops the least recently active unclaimed session, or the least recently active one if all are claimed. */
199
+ evictIssuedSession() {
200
+ let oldestUnclaimed;
201
+ let oldest;
202
+ for (const [id, entry] of this.issuedSessions) {
203
+ if (!oldest || entry.lastActive < oldest[1])
204
+ oldest = [id, entry.lastActive];
205
+ if (!entry.subject && (!oldestUnclaimed || entry.lastActive < oldestUnclaimed[1])) {
206
+ oldestUnclaimed = [id, entry.lastActive];
207
+ }
208
+ }
209
+ const victim = (oldestUnclaimed ?? oldest)?.[0];
210
+ if (victim === undefined)
211
+ return;
212
+ this.issuedSessions.delete(victim);
213
+ const now = Date.now();
214
+ if (now - this.lastSessionCapWarning > 60_000) {
215
+ this.lastSessionCapWarning = now;
216
+ this.registry.logger.warn('Issued session cap reached; evicting idle sessions', {
217
+ cap: ModernProtocolAdapter.MAX_ISSUED_SESSIONS,
218
+ });
219
+ }
220
+ }
221
+ sweepIssuedSessions() {
222
+ const now = Date.now();
223
+ for (const [id, entry] of this.issuedSessions) {
224
+ if (now - entry.lastActive > ModernProtocolAdapter.ISSUED_SESSION_TTL_MS) {
225
+ this.issuedSessions.delete(id);
226
+ }
227
+ }
228
+ }
229
+ /**
230
+ * When visibility is installed, an HTTP session id must be one `issueSession` recorded.
231
+ * A missing or unknown id throws. Stdio does not call this.
232
+ */
233
+ httpSessionOrThrow(sessionId) {
234
+ if (!this.visibilityRequiresSession())
235
+ return sessionId;
236
+ if (!sessionId || !this.touchIssuedSession(sessionId)) {
237
+ throw sessionRequiredError();
238
+ }
239
+ return sessionId;
240
+ }
241
+ /**
242
+ * The first verified subject to present a minted id owns it.
243
+ * A later call with a different subject, or with no subject, is rejected.
244
+ * An id that has only been used anonymously stays anonymous until a subject claims it.
245
+ */
246
+ bindIssuedSubject(sessionId, authInfo) {
247
+ if (!this.visibilityRequiresSession() || !sessionId)
248
+ return;
249
+ const entry = this.issuedSessions.get(sessionId);
250
+ if (!entry)
251
+ return;
252
+ const subject = this.verifiedSubject(authInfo);
253
+ if (entry.subject && entry.subject !== subject) {
254
+ throw sessionRequiredError();
255
+ }
256
+ if (!entry.subject && subject) {
257
+ entry.subject = subject;
258
+ }
259
+ }
260
+ verifiedSubject(authInfo) {
261
+ if (!authInfo)
262
+ return undefined;
263
+ const subject = this.mapAuthInfo(authInfo)?.subject;
264
+ return typeof subject === 'string' && subject.length > 0 ? subject : undefined;
265
+ }
266
+ /**
267
+ * One context for tools/list, tools/call, and resources/read.
268
+ * `authInfo` is the verified SDK principal. Metadata may still carry a raw
269
+ * bearer token; `createExecutionContext` does not use that token as the
270
+ * isolation subject.
271
+ */
272
+ executionContextFromRequest(input) {
273
+ return this.registry.createExecutionContext({
274
+ toolName: input.toolName,
275
+ metadata: input.metadata,
276
+ extra: {
277
+ sessionId: input.sessionId,
278
+ protocolVersion: input.protocolVersion ?? MODERN_PROTOCOL_VERSION,
279
+ clientInfo: input.clientInfo,
280
+ clientCapabilities: input.clientCapabilities,
281
+ requestState: input.requestState,
282
+ inputResponses: input.inputResponses,
283
+ trace: input.trace,
284
+ auth: input.authInfo ? this.mapAuthInfo(input.authInfo) : undefined,
285
+ },
286
+ });
287
+ }
78
288
  /** Server-level per-operation cache hints for list/discover results. */
79
289
  buildServerCacheHints() {
80
290
  // Conservative shared default for list surfaces; per-resource hints still win.
81
291
  return undefined;
82
292
  }
83
- async registerTools(server, sdk) {
84
- for (const tool of this.registry.getTools().values()) {
85
- const inputSchema = await this.toModernSchema(tool.inputSchema, 'input', sdk);
86
- const outputSchema = tool.outputSchema
87
- ? await this.toModernSchema(tool.outputSchema, 'output', sdk)
88
- : undefined;
89
- const config = {
90
- description: tool.description,
91
- inputSchema,
92
- };
93
- if (tool.title)
94
- config.title = tool.title;
95
- if (outputSchema)
96
- config.outputSchema = outputSchema;
97
- if (tool.annotations)
98
- config.annotations = tool.annotations;
99
- const meta = {};
100
- const cacheHint = resolveToolCacheHint(tool);
101
- if (cacheHint)
102
- meta['io.modelcontextprotocol/cacheHint'] = cacheHint;
103
- if (tool.hasComponent && tool.hasComponent()) {
104
- const component = tool.getComponent();
105
- const resourceUri = component.getResourceUri();
106
- const componentMeta = component.getResourceMetadata();
107
- meta['ui/template'] = resourceUri;
108
- meta['openai/outputTemplate'] = resourceUri;
109
- meta['ui'] = { resourceUri };
110
- if (componentMeta) {
111
- if (componentMeta['openai/widgetCSP'] !== undefined) {
112
- meta['openai/widgetCSP'] = componentMeta['openai/widgetCSP'];
113
- }
114
- if (componentMeta['openai/widgetDescription'] !== undefined) {
115
- meta['openai/widgetDescription'] = componentMeta['openai/widgetDescription'];
116
- }
117
- if (componentMeta['openai/widgetPrefersBorder'] !== undefined) {
118
- meta['openai/widgetPrefersBorder'] = componentMeta['openai/widgetPrefersBorder'];
119
- }
120
- if (componentMeta['openai/widgetDomain'] !== undefined) {
121
- meta['openai/widgetDomain'] = componentMeta['openai/widgetDomain'];
122
- }
123
- }
124
- }
125
- else if (tool.widget?.route || tool.outputTemplate) {
126
- const route = tool.widget?.route || tool.outputTemplate;
127
- const normalized = route?.startsWith('/') ? route : `/${route}`;
128
- const resourceUri = `/widgets${normalized}`;
129
- meta['ui/template'] = resourceUri;
130
- meta['openai/outputTemplate'] = resourceUri;
131
- meta['ui'] = { resourceUri };
132
- }
133
- if (tool.examples) {
134
- meta['tool/examples'] = tool.examples;
293
+ async registerTools(server, sdk, requestContext) {
294
+ const tools = await this.registry.getTransformedTools(requestContext);
295
+ const handles = new Map();
296
+ for (const tool of tools.values()) {
297
+ handles.set(tool.name, await this.registerOneTool(server, sdk, tool));
298
+ }
299
+ return handles;
300
+ }
301
+ /**
302
+ * The stdio SDK pins one server per connection, so its catalog is built once.
303
+ * Re-run the pipeline for the peer session and add or remove registrations to
304
+ * match. Each change makes the SDK send `notifications/tools/list_changed`.
305
+ */
306
+ resyncStdioTools() {
307
+ const catalog = this.stdioCatalog;
308
+ if (!catalog)
309
+ return;
310
+ this.stdioResync = this.stdioResync
311
+ .then(async () => {
312
+ if (this.stdioCatalog !== catalog)
313
+ return;
314
+ const context = this.executionContextFromRequest({ sessionId: this.stdioSessionId });
315
+ const next = await this.registry.getTransformedTools(context);
316
+ for (const [name, handle] of [...catalog.handles]) {
317
+ if (next.has(name))
318
+ continue;
319
+ handle.remove();
320
+ catalog.handles.delete(name);
135
321
  }
136
- if (tool.isInitial) {
137
- meta['tool/initial'] = true;
322
+ for (const tool of next.values()) {
323
+ if (catalog.handles.has(tool.name))
324
+ continue;
325
+ catalog.handles.set(tool.name, await this.registerOneTool(catalog.server, catalog.sdk, tool));
138
326
  }
139
- if (Object.keys(meta).length > 0) {
140
- config._meta = meta;
327
+ })
328
+ .catch((error) => {
329
+ this.registry.logger.warn('Failed to refresh the stdio tool catalog', {
330
+ error: error instanceof Error ? error.message : String(error),
331
+ });
332
+ });
333
+ }
334
+ async registerOneTool(server, sdk, tool) {
335
+ const inputSchema = await this.toModernSchema(tool.inputSchema, 'input', sdk);
336
+ const outputSchema = tool.outputSchema
337
+ ? await this.toModernSchema(tool.outputSchema, 'output', sdk)
338
+ : undefined;
339
+ const config = {
340
+ description: tool.description,
341
+ inputSchema,
342
+ };
343
+ if (tool.title)
344
+ config.title = tool.title;
345
+ if (outputSchema)
346
+ config.outputSchema = outputSchema;
347
+ if (tool.annotations)
348
+ config.annotations = tool.annotations;
349
+ const meta = {};
350
+ const cacheHint = resolveToolCacheHint(tool);
351
+ if (cacheHint)
352
+ meta['io.modelcontextprotocol/cacheHint'] = cacheHint;
353
+ if (tool.hasComponent && tool.hasComponent()) {
354
+ const component = tool.getComponent();
355
+ const resourceUri = component.getResourceUri();
356
+ const componentMeta = component.getResourceMetadata();
357
+ meta['ui/template'] = resourceUri;
358
+ meta['openai/outputTemplate'] = resourceUri;
359
+ meta['ui'] = { resourceUri };
360
+ if (componentMeta) {
361
+ if (componentMeta['openai/widgetCSP'] !== undefined) {
362
+ meta['openai/widgetCSP'] = componentMeta['openai/widgetCSP'];
363
+ }
364
+ if (componentMeta['openai/widgetDescription'] !== undefined) {
365
+ meta['openai/widgetDescription'] = componentMeta['openai/widgetDescription'];
366
+ }
367
+ if (componentMeta['openai/widgetPrefersBorder'] !== undefined) {
368
+ meta['openai/widgetPrefersBorder'] = componentMeta['openai/widgetPrefersBorder'];
369
+ }
370
+ if (componentMeta['openai/widgetDomain'] !== undefined) {
371
+ meta['openai/widgetDomain'] = componentMeta['openai/widgetDomain'];
372
+ }
141
373
  }
142
- server.registerTool(tool.name, config, async (args, ctx) => this.runTool(tool, args, ctx, sdk));
143
374
  }
375
+ else if (tool.widget?.route || tool.outputTemplate) {
376
+ const route = tool.widget?.route || tool.outputTemplate;
377
+ const normalized = route?.startsWith('/') ? route : `/${route}`;
378
+ const resourceUri = `/widgets${normalized}`;
379
+ meta['ui/template'] = resourceUri;
380
+ meta['openai/outputTemplate'] = resourceUri;
381
+ meta['ui'] = { resourceUri };
382
+ }
383
+ if (tool.examples) {
384
+ meta['tool/examples'] = tool.examples;
385
+ }
386
+ if (tool.isInitial) {
387
+ meta['tool/initial'] = true;
388
+ }
389
+ if (Object.keys(meta).length > 0) {
390
+ config._meta = meta;
391
+ }
392
+ return server.registerTool(tool.name, config, async (args, ctx) => {
393
+ // Build the context first so resolution is session-aware: authorization
394
+ // transforms (session visibility) need the sessionId to decide.
395
+ const context = this.buildContext(ctx, { toolName: tool.name });
396
+ const resolved = await this.registry.resolveTool(tool.name, context);
397
+ if (!resolved) {
398
+ const Missing = sdk.MethodNotFoundError;
399
+ if (Missing) {
400
+ throw new Missing(`Tool '${tool.name}' not found`);
401
+ }
402
+ const missing = new Error(`Tool '${tool.name}' not found`);
403
+ missing.code = -32601;
404
+ throw missing;
405
+ }
406
+ const result = await this.runTool(resolved, args, ctx, sdk, context);
407
+ // A visibility change made by this call must be listed before its result arrives.
408
+ if (this.stdioCatalog?.server === server)
409
+ await this.stdioResync;
410
+ return result;
411
+ });
144
412
  }
145
- async registerResources(server, sdk) {
413
+ async registerResources(server, sdk, requestContext) {
146
414
  // Static resources and template resources both flow through registerResource.
147
415
  const templateResources = this.registry.getTemplateResources();
148
416
  for (const resource of this.registry.getResources().values()) {
@@ -159,7 +427,7 @@ export class ModernProtocolAdapter {
159
427
  config.title = resource.title;
160
428
  if (cacheHint)
161
429
  config.cacheHint = cacheHint;
162
- server.registerResource(resource.name, resource.uri, config, async (uri) => this.readResource(String(uri?.href ?? uri), resource, sdk));
430
+ server.registerResource(resource.name, resource.uri, config, async (uri) => this.readResource(String(uri?.href ?? uri), resource, sdk, requestContext));
163
431
  }
164
432
  // Template resources keyed by uri template.
165
433
  for (const [uriTemplate, resource] of templateResources.entries()) {
@@ -177,7 +445,7 @@ export class ModernProtocolAdapter {
177
445
  const template = ResourceTemplateCtor
178
446
  ? new ResourceTemplateCtor(uriTemplate, { list: undefined })
179
447
  : uriTemplate;
180
- server.registerResource(resource.name, template, config, async (uri) => this.readResource(String(uri?.href ?? uri), resource, sdk));
448
+ server.registerResource(resource.name, template, config, async (uri) => this.readResource(String(uri?.href ?? uri), resource, sdk, requestContext));
181
449
  }
182
450
  catch (err) {
183
451
  this.registry.logger.warn('Failed to register modern resource template', {
@@ -194,10 +462,12 @@ export class ModernProtocolAdapter {
194
462
  if ((rawResources.size > 0 || templateResources.size > 0) && server.server && typeof server.server.setRequestHandler === 'function') {
195
463
  server.server.setRequestHandler('resources/read', async (request, ctx) => {
196
464
  const reqUri = String(request?.params?.uri ?? '');
465
+ const fromCall = ctx ? this.buildContext(ctx, {}) : undefined;
466
+ const readContext = fromCall?.sessionId ? fromCall : requestContext;
197
467
  // 1. Check exact match in registered resources (including path-based URIs like /widgets/...)
198
468
  const matchingResource = rawResources.get(reqUri);
199
469
  if (matchingResource) {
200
- const resResult = await this.readResource(reqUri, matchingResource, sdk);
470
+ const resResult = await this.readResource(reqUri, matchingResource, sdk, readContext);
201
471
  const cacheHint = resolveResourceCacheHint(matchingResource);
202
472
  if (cacheHint) {
203
473
  return { ...resResult, cacheHint };
@@ -211,10 +481,9 @@ export class ModernProtocolAdapter {
211
481
  }
212
482
  catch {
213
483
  // If not parseable as standard URL, check if any resource matches
214
- for (const [uri, res] of rawResources.entries()) {
215
- if (uri === reqUri || uri.endsWith(reqUri) || reqUri.endsWith(uri)) {
216
- return this.readResource(reqUri, res, sdk);
217
- }
484
+ const exact = rawResources.get(reqUri);
485
+ if (exact) {
486
+ return this.readResource(reqUri, exact, sdk, readContext);
218
487
  }
219
488
  }
220
489
  if (parsedUrl) {
@@ -224,7 +493,7 @@ export class ModernProtocolAdapter {
224
493
  if (typeof registered.readCallback === 'function') {
225
494
  return registered.readCallback(parsedUrl, ctx);
226
495
  }
227
- return this.readResource(reqUri, registered, sdk);
496
+ return this.readResource(reqUri, registered, sdk, readContext);
228
497
  }
229
498
  }
230
499
  // 3. Check template resources
@@ -296,8 +565,8 @@ export class ModernProtocolAdapter {
296
565
  // ==========================================================================
297
566
  // Handlers
298
567
  // ==========================================================================
299
- async runTool(tool, args, ctx, sdk) {
300
- const context = this.buildContext(ctx, { toolName: tool.name });
568
+ async runTool(tool, args, ctx, sdk, prebuiltContext) {
569
+ const context = prebuiltContext ?? this.buildContext(ctx, { toolName: tool.name });
301
570
  const isTaskAugmented = ctx?.task !== undefined || ctx?.mcpReq?.params?.task !== undefined;
302
571
  // Enforce tool-level task support negotiation
303
572
  if (tool.taskSupport === 'required' && !isTaskAugmented) {
@@ -401,10 +670,14 @@ export class ModernProtocolAdapter {
401
670
  err.data = mapped.data;
402
671
  return err;
403
672
  }
404
- async readResource(uri, resource, sdk) {
405
- const context = this.registry.createExecutionContext({
406
- extra: { protocolVersion: MODERN_PROTOCOL_VERSION },
407
- });
673
+ async readResource(uri, resource, sdk, requestContext) {
674
+ // requestContext already passed through createExecutionContext once.
675
+ // Feeding its sessionId back in as extra.sessionId runs sessionIsolationKey
676
+ // a second time and the spillover read no longer matches the write.
677
+ const context = requestContext ??
678
+ this.registry.createExecutionContext({
679
+ extra: { protocolVersion: MODERN_PROTOCOL_VERSION },
680
+ });
408
681
  let content;
409
682
  try {
410
683
  content = await resource.fetch(context, uri);
@@ -455,11 +728,9 @@ export class ModernProtocolAdapter {
455
728
  }
456
729
  const trace = extractTraceContext({ ...meta, ...envelope });
457
730
  const inputResponses = mcpReq.inputResponses;
458
- const authInfo = readEnvelope('auth', 'io.modelcontextprotocol/auth') ??
459
- ctx?.http?.authInfo ??
460
- mcpReq?.http?.authInfo ??
461
- ctx?.authInfo ??
462
- ctx?.auth;
731
+ // Envelope and `_meta` are the client request. A subject there must not
732
+ // become the isolation principal. Only auth the host or SDK attached.
733
+ const authInfo = this.trustedAuthInfo(ctx);
463
734
  const rawHeaders = {};
464
735
  const reqHeaders = ctx?.http?.req?.headers ||
465
736
  ctx?.http?.headers ||
@@ -520,18 +791,33 @@ export class ModernProtocolAdapter {
520
791
  metadata._oauth = rawToken;
521
792
  metadata.jwtToken = rawToken;
522
793
  }
523
- return this.registry.createExecutionContext({
794
+ // Header only. requestState is client-echoed MRTR state and must not select a session.
795
+ let sessionId = rawHeaders['mcp-session-id'] ||
796
+ rawHeaders['Mcp-Session-Id'] ||
797
+ ctx?.request?.headers?.get?.('mcp-session-id') ||
798
+ ctx?.req?.headers?.get?.('mcp-session-id') ||
799
+ undefined;
800
+ const hasHttpRequest = Boolean(reqHeaders || ctx?.http || ctx?.request || ctx?.req);
801
+ const usingStdioPeer = !hasHttpRequest && Boolean(this.stdioSessionId);
802
+ if (usingStdioPeer) {
803
+ // The stdio peer has one minted id. A header here is not a second session.
804
+ sessionId = this.stdioSessionId;
805
+ }
806
+ else {
807
+ sessionId = this.httpSessionOrThrow(sessionId);
808
+ this.bindIssuedSubject(sessionId, authInfo);
809
+ }
810
+ return this.executionContextFromRequest({
524
811
  toolName: opts.toolName,
525
812
  metadata,
526
- extra: {
527
- protocolVersion,
528
- clientInfo,
529
- clientCapabilities,
530
- requestState,
531
- inputResponses,
532
- trace,
533
- auth: authInfo ? this.mapAuthInfo(authInfo) : undefined,
534
- },
813
+ sessionId,
814
+ authInfo,
815
+ protocolVersion,
816
+ clientInfo,
817
+ clientCapabilities,
818
+ requestState,
819
+ inputResponses,
820
+ trace,
535
821
  });
536
822
  }
537
823
  mapAuthInfo(authInfo) {
@@ -581,7 +867,7 @@ export class ModernProtocolAdapter {
581
867
  async getHttpHandler() {
582
868
  if (!this.handler) {
583
869
  const sdk = await this.loadServerSdk();
584
- const rawHandler = sdk.createMcpHandler(() => this.buildServer(), {
870
+ const rawHandler = sdk.createMcpHandler((ctx) => this.buildServer(ctx, 'http'), {
585
871
  legacy: this.options.legacyMode,
586
872
  onerror: (error) => {
587
873
  this.registry.logger.error('Modern MCP handler error', { error: error.message });
@@ -591,6 +877,11 @@ export class ModernProtocolAdapter {
591
877
  this.handler = {
592
878
  ...rawHandler,
593
879
  fetch: async (request, requestOptions) => {
880
+ if (this.visibilityRequiresSession()) {
881
+ const blocked = await this.visibilityHttpGate(request);
882
+ if (blocked)
883
+ return blocked;
884
+ }
594
885
  if (request.headers.get('mcp-method') === 'ping') {
595
886
  try {
596
887
  const clone = request.clone();
@@ -624,23 +915,209 @@ export class ModernProtocolAdapter {
624
915
  // Not JSON or cannot intercept; let rawFetch handle
625
916
  }
626
917
  }
627
- return rawFetch(request, requestOptions);
918
+ const response = await rawFetch(request, requestOptions);
919
+ if (this.visibilityRequiresSession()) {
920
+ return this.attachIssuedSession(request, response);
921
+ }
922
+ return response;
628
923
  },
629
924
  };
630
925
  }
631
926
  return this.handler;
632
927
  }
633
- extractAccessContext(req, parsedBody) {
928
+ sessionRequiredResponse(id) {
929
+ return new Response(JSON.stringify({
930
+ jsonrpc: '2.0',
931
+ id,
932
+ error: { code: -32600, message: 'Session required' },
933
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
934
+ }
935
+ /**
936
+ * Session gate for the modern HTTP fetch path.
937
+ * `initialize`, `ping`, and `server/discover` proceed without a session.
938
+ * `tools/list`, `tools/call`, and `resources/read` need an id this process minted.
939
+ * A POST whose method cannot be read fails closed.
940
+ */
941
+ async visibilityHttpGate(request) {
942
+ const headerMethod = request.headers.get('mcp-method') || request.headers.get('Mcp-Method');
943
+ let bodyMethod;
944
+ let bodyId = null;
945
+ let bodyReadable = true;
946
+ if (request.method === 'POST') {
947
+ try {
948
+ const body = (await request.clone().json());
949
+ bodyMethod = typeof body?.method === 'string' ? body.method : undefined;
950
+ bodyId = body?.id ?? null;
951
+ }
952
+ catch {
953
+ bodyReadable = false;
954
+ }
955
+ }
956
+ if (request.method === 'POST' && !bodyReadable && !headerMethod) {
957
+ return this.sessionRequiredResponse(null);
958
+ }
959
+ // The body is the method that will run. A session-free header must not
960
+ // hide tasks/get or tools/call.
961
+ if (headerMethod && bodyMethod && headerMethod !== bodyMethod) {
962
+ return this.sessionRequiredResponse(bodyId);
963
+ }
964
+ const method = bodyMethod ?? headerMethod;
965
+ this.gatedMethods.set(request, typeof method === 'string' ? method : undefined);
966
+ if (this.isSessionFreeMethod(method)) {
967
+ return null;
968
+ }
969
+ const session = request.headers.get('mcp-session-id') || request.headers.get('Mcp-Session-Id') || undefined;
970
+ const needsSession = method === 'tools/list' ||
971
+ method === 'tools/call' ||
972
+ method === 'resources/read' ||
973
+ method === 'tasks/get' ||
974
+ method === 'tasks/cancel' ||
975
+ method === 'tasks/update' ||
976
+ (!method && request.method === 'POST');
977
+ if ((needsSession && !session) || (session && !this.touchIssuedSession(session))) {
978
+ return this.sessionRequiredResponse(bodyId);
979
+ }
980
+ return null;
981
+ }
982
+ /** Stamp a freshly minted session id onto a successful initialize response. */
983
+ async attachIssuedSession(request, response) {
984
+ const headerMethod = request.headers.get('mcp-method') || request.headers.get('Mcp-Method');
985
+ const method = this.gatedMethods.get(request) ?? headerMethod;
986
+ if (method !== 'initialize' || !response.ok)
987
+ return response;
988
+ const contentType = response.headers.get('content-type');
989
+ const text = await this.readResponseText(response, contentType);
990
+ const parsed = this.extractRpcMessage(text, contentType);
991
+ const headers = new Headers(response.headers);
992
+ if (parsed && parsed.error == null && parsed.result) {
993
+ headers.set('mcp-session-id', this.issueSession());
994
+ }
995
+ return new Response(text, { status: response.status, statusText: response.statusText, headers });
996
+ }
997
+ /**
998
+ * Read a finite JSON body, or the first SSE frame.
999
+ * A legacy initialize answer is a stream that stays open after the handshake.
1000
+ */
1001
+ async readResponseText(response, contentType) {
1002
+ if (!contentType?.includes('text/event-stream') || !response.body) {
1003
+ return response.text();
1004
+ }
1005
+ const reader = response.body.getReader();
1006
+ const decoder = new TextDecoder();
1007
+ let buffer = '';
1008
+ const deadline = Date.now() + 5000;
1009
+ try {
1010
+ while (Date.now() < deadline) {
1011
+ const { value, done } = await reader.read();
1012
+ if (value)
1013
+ buffer += decoder.decode(value, { stream: true });
1014
+ if (this.extractRpcMessage(buffer, contentType))
1015
+ break;
1016
+ if (done)
1017
+ break;
1018
+ }
1019
+ }
1020
+ finally {
1021
+ await reader.cancel().catch(() => undefined);
1022
+ }
1023
+ return buffer;
1024
+ }
1025
+ /** First JSON-RPC message from a JSON body or an SSE `data:` frame. */
1026
+ extractRpcMessage(text, contentType) {
1027
+ if (contentType?.includes('text/event-stream')) {
1028
+ for (const line of text.split('\n')) {
1029
+ const trimmed = line.trim();
1030
+ if (!trimmed.startsWith('data:'))
1031
+ continue;
1032
+ try {
1033
+ const parsed = JSON.parse(trimmed.slice(5).trim());
1034
+ if (parsed && (parsed.result !== undefined || parsed.error !== undefined))
1035
+ return parsed;
1036
+ }
1037
+ catch {
1038
+ /* keep scanning */
1039
+ }
1040
+ }
1041
+ return undefined;
1042
+ }
1043
+ try {
1044
+ return JSON.parse(text);
1045
+ }
1046
+ catch {
1047
+ return undefined;
1048
+ }
1049
+ }
1050
+ /** Raw mcp-session-id from a Web Request or an Express request. Not an isolation key. */
1051
+ requestSessionId(req) {
634
1052
  const reqAny = req;
635
- const auth = reqAny?.auth || reqAny?.user;
1053
+ const headers = reqAny?.headers;
1054
+ const fromObject = headers?.['mcp-session-id'] || headers?.['Mcp-Session-Id'];
1055
+ const fromGetter = typeof headers?.get === 'function'
1056
+ ? headers.get('mcp-session-id') || headers.get('Mcp-Session-Id')
1057
+ : undefined;
1058
+ const fromExpress = typeof reqAny?.get === 'function'
1059
+ ? reqAny.get('mcp-session-id') || reqAny.get('Mcp-Session-Id')
1060
+ : undefined;
1061
+ const value = fromObject || fromGetter || fromExpress;
1062
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
1063
+ }
1064
+ /**
1065
+ * Principal attached by the host or the SDK.
1066
+ * `auth` on the request body and `_meta` are not verified and are ignored.
1067
+ */
1068
+ trustedAuthInfo(source) {
1069
+ if (!source || typeof source !== 'object')
1070
+ return undefined;
1071
+ const mcpReq = (source.mcpReq ?? {});
1072
+ const candidates = [source.http?.authInfo, mcpReq.http?.authInfo, source.authInfo];
1073
+ for (const candidate of candidates) {
1074
+ if (candidate && typeof candidate === 'object')
1075
+ return candidate;
1076
+ }
1077
+ return undefined;
1078
+ }
1079
+ /** SDK authInfo when the host attached it. A bearer header is not verified here. */
1080
+ requestAuthInfo(req) {
1081
+ return this.trustedAuthInfo(req);
1082
+ }
1083
+ extractAccessContext(req, _parsedBody) {
1084
+ const reqAny = req;
1085
+ const auth = (reqAny?.authInfo || reqAny?.auth || reqAny?.user);
636
1086
  const userId = auth?.sub || auth?.userId || auth?.id;
637
1087
  const tenantId = auth?.tenantId || auth?.orgId;
638
- const sessionId = reqAny?.headers?.['mcp-session-id'] || reqAny?.get?.('mcp-session-id');
1088
+ const sessionId = this.requestSessionId(req);
639
1089
  if (!userId && !tenantId && !sessionId) {
640
1090
  return undefined;
641
1091
  }
642
1092
  return { userId, tenantId, sessionId };
643
1093
  }
1094
+ /**
1095
+ * tasks/get, tasks/cancel, and tasks/update carry tool results. When visibility
1096
+ * is on they need the same minted session as tools/call. params.sessionId is
1097
+ * not a credential.
1098
+ */
1099
+ taskAccessForRequest(req, id, accessContext) {
1100
+ if (!this.visibilityRequiresSession()) {
1101
+ return { access: accessContext };
1102
+ }
1103
+ try {
1104
+ const issued = this.httpSessionOrThrow(this.requestSessionId(req));
1105
+ this.bindIssuedSubject(issued, this.requestAuthInfo(req));
1106
+ return { access: { ...(accessContext ?? {}), sessionId: issued } };
1107
+ }
1108
+ catch (err) {
1109
+ return {
1110
+ error: {
1111
+ jsonrpc: '2.0',
1112
+ id: id ?? null,
1113
+ error: {
1114
+ code: -32600,
1115
+ message: err instanceof Error ? err.message : 'Session required',
1116
+ },
1117
+ },
1118
+ };
1119
+ }
1120
+ }
644
1121
  async handleTaskPreDispatch(body, req) {
645
1122
  if (!this.taskManager || !body || typeof body !== 'object')
646
1123
  return null;
@@ -652,8 +1129,11 @@ export class ModernProtocolAdapter {
652
1129
  if (!taskId) {
653
1130
  return { jsonrpc: '2.0', id: id ?? null, error: { code: -32602, message: 'Invalid params: taskId is required' } };
654
1131
  }
1132
+ const gated = this.taskAccessForRequest(req, id, accessContext);
1133
+ if (gated.error)
1134
+ return gated.error;
655
1135
  try {
656
- const entry = this.taskManager.getEntry(taskId, accessContext);
1136
+ const entry = this.taskManager.getEntry(taskId, gated.access);
657
1137
  const resultPayload = { ...entry.data };
658
1138
  if (entry.data.status === 'completed' && entry.result !== undefined) {
659
1139
  resultPayload.result = entry.result;
@@ -673,8 +1153,11 @@ export class ModernProtocolAdapter {
673
1153
  if (!taskId) {
674
1154
  return { jsonrpc: '2.0', id: id ?? null, error: { code: -32602, message: 'Invalid params: taskId is required' } };
675
1155
  }
1156
+ const gated = this.taskAccessForRequest(req, id, accessContext);
1157
+ if (gated.error)
1158
+ return gated.error;
676
1159
  try {
677
- const taskData = this.taskManager.cancelTask(taskId, accessContext);
1160
+ const taskData = this.taskManager.cancelTask(taskId, gated.access);
678
1161
  return { jsonrpc: '2.0', id: id ?? null, result: taskData };
679
1162
  }
680
1163
  catch (err) {
@@ -687,8 +1170,11 @@ export class ModernProtocolAdapter {
687
1170
  if (!taskId) {
688
1171
  return { jsonrpc: '2.0', id: id ?? null, error: { code: -32602, message: 'Invalid params: taskId is required' } };
689
1172
  }
1173
+ const gated = this.taskAccessForRequest(req, id, accessContext);
1174
+ if (gated.error)
1175
+ return gated.error;
690
1176
  try {
691
- const taskData = this.taskManager.updateStatus(taskId, params.status || 'working', params.statusMessage, accessContext);
1177
+ const taskData = this.taskManager.updateStatus(taskId, params.status || 'working', params.statusMessage, gated.access);
692
1178
  return { jsonrpc: '2.0', id: id ?? null, result: taskData };
693
1179
  }
694
1180
  catch (err) {
@@ -717,14 +1203,55 @@ export class ModernProtocolAdapter {
717
1203
  },
718
1204
  };
719
1205
  }
720
- // 6. tools/call with task augmentation OR mandatory task support check
721
- if (method === 'tools/call' && params) {
1206
+ // 6. tools/call with task augmentation OR mandatory task support check.
1207
+ // Resolution goes through the transform chain so session visibility still applies.
1208
+ // The raw catalog lookup used to execute hidden and disabled tools.
1209
+ if (method === 'tools/call' && params && typeof params.name === 'string') {
722
1210
  const toolName = params.name;
723
- const tool = this.registry.getTools().get(toolName);
724
- if (!tool)
725
- return null; // Let standard flow handle tool not found
1211
+ let issuedSession;
1212
+ const taskAuth = this.requestAuthInfo(req);
1213
+ try {
1214
+ issuedSession = this.httpSessionOrThrow(this.requestSessionId(req));
1215
+ this.bindIssuedSubject(issuedSession, taskAuth);
1216
+ }
1217
+ catch (err) {
1218
+ return {
1219
+ jsonrpc: '2.0',
1220
+ id: id ?? null,
1221
+ error: {
1222
+ code: -32600,
1223
+ message: err instanceof Error ? err.message : 'Session required',
1224
+ },
1225
+ };
1226
+ }
1227
+ const executionContext = this.executionContextFromRequest({
1228
+ toolName,
1229
+ sessionId: issuedSession,
1230
+ authInfo: taskAuth,
1231
+ });
1232
+ let tool;
1233
+ try {
1234
+ tool = await this.registry.resolveTool(toolName, executionContext);
1235
+ }
1236
+ catch (err) {
1237
+ const coded = err;
1238
+ return {
1239
+ jsonrpc: '2.0',
1240
+ id: id ?? null,
1241
+ error: {
1242
+ code: typeof coded.code === 'number' ? coded.code : -32603,
1243
+ message: err instanceof Error ? err.message : String(err),
1244
+ },
1245
+ };
1246
+ }
1247
+ if (!tool) {
1248
+ return {
1249
+ jsonrpc: '2.0',
1250
+ id: id ?? null,
1251
+ error: { code: -32601, message: `Tool '${toolName}' not found` },
1252
+ };
1253
+ }
726
1254
  const isTaskAugmented = params.task !== undefined;
727
- // Enforcement: if taskSupport === 'required' and not task-augmented
728
1255
  if (!isTaskAugmented && tool.taskSupport === 'required') {
729
1256
  return {
730
1257
  jsonrpc: '2.0',
@@ -732,58 +1259,54 @@ export class ModernProtocolAdapter {
732
1259
  error: { code: -32600, message: `Task augmentation required for tools/call requests on tool '${toolName}'` },
733
1260
  };
734
1261
  }
735
- // If task-augmented:
736
- if (isTaskAugmented) {
737
- if (tool.taskSupport === 'forbidden') {
738
- return {
739
- jsonrpc: '2.0',
740
- id: id ?? null,
741
- error: { code: -32601, message: `Tool '${toolName}' does not support task augmentation` },
742
- };
743
- }
744
- const taskData = this.taskManager.createTask(params.task, toolName, accessContext);
745
- const taskId = taskData.taskId;
746
- const taskContext = new TaskContext(this.taskManager, taskId);
747
- const executionContext = this.registry.createExecutionContext({
748
- toolName,
749
- extra: {
750
- task: taskContext,
751
- },
752
- });
753
- executionContext.task = taskContext;
754
- const tm = this.taskManager;
755
- // Run tool asynchronously in the background
756
- Promise.resolve().then(async () => {
757
- try {
758
- const argsRecord = (params.arguments || {});
759
- const { _meta: _, ...toolArgs } = argsRecord;
760
- const toolResult = await tool.execute(toolArgs, executionContext);
761
- if (tm.hasTask(taskId)) {
762
- const current = tm.getTask(taskId);
763
- if (current.status !== 'cancelled') {
764
- tm.completeTask(taskId, toolResult, undefined, accessContext);
765
- }
766
- }
767
- }
768
- catch (err) {
769
- if (tm.hasTask(taskId)) {
770
- const current = tm.getTask(taskId);
771
- if (current.status !== 'cancelled') {
772
- tm.failTask(taskId, { code: err.code || -32603, message: err.message || String(err) }, undefined, accessContext);
773
- }
774
- }
775
- }
776
- });
777
- // Return CreateTaskResult immediately
1262
+ // Synchronous tools/call stays on the registered handler, which resolves again.
1263
+ if (!isTaskAugmented)
1264
+ return null;
1265
+ if (tool.taskSupport === 'forbidden') {
778
1266
  return {
779
1267
  jsonrpc: '2.0',
780
1268
  id: id ?? null,
781
- result: {
782
- task: taskData,
783
- resultType: 'task',
784
- },
1269
+ error: { code: -32601, message: `Tool '${toolName}' does not support task augmentation` },
785
1270
  };
786
1271
  }
1272
+ const taskData = this.taskManager.createTask(params.task, toolName, accessContext);
1273
+ const taskId = taskData.taskId;
1274
+ const taskContext = new TaskContext(this.taskManager, taskId);
1275
+ executionContext.task = taskContext;
1276
+ const tm = this.taskManager;
1277
+ Promise.resolve().then(async () => {
1278
+ try {
1279
+ const argsRecord = (params.arguments || {});
1280
+ const { _meta: _, ...toolArgs } = argsRecord;
1281
+ const toolResult = await tool.execute(toolArgs, executionContext);
1282
+ if (tm.hasTask(taskId)) {
1283
+ const current = tm.getTask(taskId);
1284
+ if (current.status !== 'cancelled') {
1285
+ tm.completeTask(taskId, toolResult, undefined, accessContext);
1286
+ }
1287
+ }
1288
+ }
1289
+ catch (err) {
1290
+ if (tm.hasTask(taskId)) {
1291
+ const current = tm.getTask(taskId);
1292
+ if (current.status !== 'cancelled') {
1293
+ const coded = err;
1294
+ tm.failTask(taskId, {
1295
+ code: typeof coded.code === 'number' ? coded.code : -32603,
1296
+ message: err instanceof Error ? err.message : String(err),
1297
+ }, undefined, accessContext);
1298
+ }
1299
+ }
1300
+ }
1301
+ });
1302
+ return {
1303
+ jsonrpc: '2.0',
1304
+ id: id ?? null,
1305
+ result: {
1306
+ task: taskData,
1307
+ resultType: 'task',
1308
+ },
1309
+ };
787
1310
  }
788
1311
  return null;
789
1312
  }
@@ -865,7 +1388,8 @@ export class ModernProtocolAdapter {
865
1388
  }
866
1389
  /**
867
1390
  * SEP-2243/SEP-2575 CORS: expose and allow the new required request headers
868
- * (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`, and `Mcp-Param-*`).
1391
+ * (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`, `Mcp-Param-*`, and
1392
+ * `Mcp-Session-Id`). Session visibility and spillover both read that header.
869
1393
  */
870
1394
  applyCorsHeaders(req, res) {
871
1395
  const origin = req.headers.origin;
@@ -879,20 +1403,26 @@ export class ModernProtocolAdapter {
879
1403
  'Mcp-Method',
880
1404
  'Mcp-Name',
881
1405
  'Mcp-Param-*',
1406
+ 'Mcp-Session-Id',
882
1407
  'Last-Event-ID',
883
1408
  ].join(', '));
884
1409
  res.setHeader('Access-Control-Expose-Headers', ['MCP-Protocol-Version', 'Mcp-Method', 'Mcp-Name'].join(', '));
885
1410
  }
886
1411
  async serveStdio() {
1412
+ this.stdioSessionId ??= crypto.randomUUID();
887
1413
  const stdio = (await import('@modelcontextprotocol/server/stdio'));
888
- this.stdioHandle = stdio.serveStdio(() => this.buildServer());
1414
+ this.stdioHandle = stdio.serveStdio((ctx) => this.buildServer(ctx, 'stdio'));
889
1415
  this.registry.logger.info(`Modern MCP (${MODERN_PROTOCOL_VERSION}) serving over stdio`);
890
1416
  }
891
1417
  // ==========================================================================
892
1418
  // Notifications (subscriptions/listen bus)
893
1419
  // ==========================================================================
894
- notifyToolsListChanged() {
895
- this.handler?.notify?.toolsChanged?.();
1420
+ notifyToolsListChanged(sessionId) {
1421
+ if (!sessionId || sessionId === this.stdioSessionId) {
1422
+ this.resyncStdioTools();
1423
+ }
1424
+ this.handler?.notify?.toolsChanged?.(sessionId);
1425
+ this.handler?.bus?.emit?.('tools_changed', sessionId ? { sessionId } : {});
896
1426
  }
897
1427
  notifyResourcesListChanged() {
898
1428
  this.handler?.notify?.resourcesChanged?.();
@@ -927,6 +1457,7 @@ export class ModernProtocolAdapter {
927
1457
  }
928
1458
  this.handler = undefined;
929
1459
  this.stdioHandle = undefined;
1460
+ this.stdioCatalog = undefined;
930
1461
  }
931
1462
  /** The extensions map this adapter would advertise (for diagnostics/notes). */
932
1463
  advertisedExtensions() {