@tmcp/session-manager 0.2.2 → 0.3.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/README.md CHANGED
@@ -10,10 +10,11 @@ pnpm add @tmcp/session-manager
10
10
 
11
11
  ## Overview
12
12
 
13
- Session management is split into two concerns:
13
+ Session management is split into three concerns:
14
14
 
15
15
  - **Stream session managers** handle the storage of long-lived streaming connections (SSE/HTTP) and the fan-out of notifications back to the right session.
16
16
  - **Info session managers** persist metadata that MCP transports need across requests, such as client capabilities, client info, requested log level, and resource subscriptions.
17
+ - **Subscription managers** route MCP `2026-07-28` per-request change notifications to long-lived `subscriptions/listen` streams.
17
18
 
18
19
  Together they manage:
19
20
 
@@ -23,6 +24,7 @@ Together they manage:
23
24
  - **Message Delivery**: Sending messages to specific sessions or everyone
24
25
  - **Client Metadata**: Persisting capabilities, `clientInfo`, and log level between requests
25
26
  - **Resource Subscriptions**: Tracking which sessions subscribed to which URIs
27
+ - **Per-Request Subscriptions**: Acknowledging, filtering, ordering, and closing sessionless notification streams
26
28
 
27
29
  ## Usage
28
30
 
@@ -42,6 +44,8 @@ const sessionManagers = {
42
44
  };
43
45
  ```
44
46
 
47
+ `InMemorySubscriptionManager` is the default for transports that support the per-request protocol. It preserves JSON-RPC ID types, buffers changes until acknowledgement completes, and serializes delivery per subscription.
48
+
45
49
  ### Custom Session Managers
46
50
 
47
51
  You can implement your own managers by extending the base classes that ship with this package.
@@ -108,6 +112,10 @@ class CustomInfoSessionManager extends InfoSessionManager {
108
112
  // Track that the session subscribed to the URI
109
113
  }
110
114
 
115
+ removeSubscription(id, uri) {
116
+ // Stop tracking this resource subscription
117
+ }
118
+
111
119
  delete(id) {
112
120
  // Remove all metadata for the session (client info, capabilities, subscriptions, etc.)
113
121
  }
@@ -116,6 +124,17 @@ class CustomInfoSessionManager extends InfoSessionManager {
116
124
 
117
125
  ## API
118
126
 
127
+ ### `SubscriptionManager` (Abstract Base Class)
128
+
129
+ Transport-owned manager for `subscriptions/listen` registrations.
130
+
131
+ - `create(subscription, callbacks)` – atomically register `{ id, origin, filters }` and acknowledge before delivering buffered changes
132
+ - `send(notification)` – route one change to every matching registration
133
+ - `close(id, origin, reason)` – close one registration without conflating numeric and string IDs
134
+ - `closeAll(origin?, reason?)` – close all registrations, optionally for one transport origin
135
+
136
+ Only the descriptor is suitable for persistence. Callback functions remain on the instance serving the response stream; distributed implementations should use pub/sub only to fan notifications out to that instance. Registration and closure stay local to the process that owns the response stream.
137
+
119
138
  ### `StreamSessionManager` (Abstract Base Class)
120
139
 
121
140
  Responsible for creating and managing streaming controllers.
@@ -134,6 +153,7 @@ Stores session metadata that needs to survive across HTTP requests or reconnects
134
153
  - `getLogLevel(id)` / `setLogLevel(id, level)`
135
154
  - `getSubscriptions(uri)` – return all session IDs that subscribed to a resource
136
155
  - `addSubscription(id, uri)` – record a new resource subscription
156
+ - `removeSubscription(id, uri)` – remove one resource subscription
137
157
  - `delete(id)` – remove all metadata for a session when it disconnects
138
158
 
139
159
  ### `InMemoryStreamSessionManager` & `InMemoryInfoSessionManager`
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@tmcp/session-manager",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "description": "",
6
6
  "main": "src/index.js",
7
+ "files": [
8
+ "src/"
9
+ ],
7
10
  "repository": {
8
11
  "type": "git",
9
12
  "url": "git+https://github.com/paoloricciuti/tmcp.git",
@@ -25,7 +28,7 @@
25
28
  },
26
29
  "type": "module",
27
30
  "peerDependencies": {
28
- "tmcp": "^1.16.3"
31
+ "tmcp": "^1.20.0-next.0 || ^1.20.0"
29
32
  },
30
33
  "publishConfig": {
31
34
  "access": "public"
@@ -33,11 +36,16 @@
33
36
  "devDependencies": {
34
37
  "dts-buddy": "^0.6.2",
35
38
  "publint": "^0.3.12",
36
- "tmcp": "^1.19.4",
39
+ "vitest": "^4.0.0",
40
+ "tmcp": "^1.20.0",
37
41
  "@tmcp/internal-ts-helpers": "^1.0.0"
38
42
  },
43
+ "dependencies": {
44
+ "json-rpc-2.0": "^1.7.1"
45
+ },
39
46
  "scripts": {
40
47
  "generate:types": "dts-buddy && node scripts/abstractify.js && publint",
41
- "prepublish": "pnpm generate:types"
48
+ "prepublish": "pnpm generate:types",
49
+ "test": "vitest --run"
42
50
  }
43
51
  }
package/src/index.js CHANGED
@@ -1,9 +1,255 @@
1
1
  /* eslint-disable no-unused-vars */
2
2
 
3
3
  /**
4
- * @import { Context } from "tmcp";
4
+ * @import { Context, SubscriptionFilter } from "tmcp";
5
+ * @import { JSONRPCRequest } from "json-rpc-2.0";
5
6
  */
6
7
 
8
+ /**
9
+ * @typedef {{ id: string | number, origin: string, filters: SubscriptionFilter }} Subscription
10
+ * @typedef {{ acknowledge: () => void | Promise<void>, send: (notification: JSONRPCRequest) => void | Promise<void>, close: (reason: 'closed' | 'cancelled') => void | Promise<void> }} SubscriptionCallbacks
11
+ */
12
+
13
+ /**
14
+ * Determine whether a notification belongs on a subscription stream.
15
+ * Distributed managers can use this directly or maintain equivalent indexes.
16
+ * @param {SubscriptionFilter} filters
17
+ * @param {JSONRPCRequest} notification
18
+ */
19
+ export function matchesSubscription(filters, notification) {
20
+ if (notification.method === 'notifications/tools/list_changed') {
21
+ return filters.toolsListChanged === true;
22
+ }
23
+ if (notification.method === 'notifications/prompts/list_changed') {
24
+ return filters.promptsListChanged === true;
25
+ }
26
+ if (notification.method === 'notifications/resources/list_changed') {
27
+ return filters.resourcesListChanged === true;
28
+ }
29
+ if (notification.method === 'notifications/resources/updated') {
30
+ const uri = /** @type {Record<string, unknown> | undefined} */ (
31
+ notification.params
32
+ )?.uri;
33
+ return (
34
+ typeof uri === 'string' &&
35
+ filters.resourceSubscriptions?.includes(uri) === true
36
+ );
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Routes notifications to long-lived per-request subscription streams.
43
+ * Callbacks remain local to the process serving the response stream. Brokered
44
+ * implementations can distribute notifications without persisting descriptors.
45
+ * @abstract
46
+ */
47
+ export class SubscriptionManager {
48
+ /**
49
+ * @abstract
50
+ * Atomically register a subscription. Matching notifications must be
51
+ * buffered until acknowledgement completes and then delivered in order.
52
+ * @param {Subscription} subscription
53
+ * @param {SubscriptionCallbacks} callbacks
54
+ * @returns {boolean | Promise<boolean>}
55
+ */
56
+ create(subscription, callbacks) {
57
+ void subscription;
58
+ void callbacks;
59
+ throw new Error('Method not implemented.');
60
+ }
61
+
62
+ /**
63
+ * @abstract
64
+ * @param {JSONRPCRequest} notification
65
+ * @returns {void | Promise<void>}
66
+ */
67
+ send(notification) {
68
+ void notification;
69
+ throw new Error('Method not implemented.');
70
+ }
71
+
72
+ /**
73
+ * @abstract
74
+ * Request closure of one subscription. Implementations must preserve the
75
+ * JSON-RPC ID type when identifying a registration.
76
+ * @param {string | number} id
77
+ * @param {string} origin
78
+ * @param {'closed' | 'cancelled'} reason
79
+ * @returns {boolean | Promise<boolean>}
80
+ */
81
+ close(id, origin, reason) {
82
+ void id;
83
+ void origin;
84
+ void reason;
85
+ throw new Error('Method not implemented.');
86
+ }
87
+
88
+ /**
89
+ * @abstract
90
+ * Close every subscription, optionally limited to one transport origin.
91
+ * @param {string} [origin]
92
+ * @param {'closed' | 'cancelled'} [reason]
93
+ * @returns {void | Promise<void>}
94
+ */
95
+ closeAll(origin, reason = 'cancelled') {
96
+ void origin;
97
+ void reason;
98
+ throw new Error('Method not implemented.');
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Process-local subscription manager. Distributed implementations should
104
+ * mirror its acknowledgement buffering and per-subscription ordering.
105
+ */
106
+ export class InMemorySubscriptionManager extends SubscriptionManager {
107
+ /** @type {Map<string, Map<string | number, { subscription: Subscription, callbacks: SubscriptionCallbacks, queue: Promise<'active' | 'failed'>, close_promise?: Promise<boolean> }>>} */
108
+ #subscriptions = new Map();
109
+
110
+ /**
111
+ * @param {{ subscription: Subscription, callbacks: SubscriptionCallbacks, queue: Promise<'active' | 'failed'>, close_promise?: Promise<boolean> }} record
112
+ * @param {JSONRPCRequest} notification
113
+ */
114
+ #enqueue(record, notification) {
115
+ const delivery = record.queue.then(async (state) => {
116
+ if (state === 'failed' || record.close_promise) return state;
117
+ await record.callbacks.send(notification);
118
+ return /** @type {const} */ ('active');
119
+ });
120
+ // A failed send rejects its caller without poisoning later queue entries.
121
+ record.queue = delivery.catch(() => 'active');
122
+ return delivery.then(() => {});
123
+ }
124
+
125
+ /**
126
+ * @param {Subscription} subscription
127
+ * @param {SubscriptionCallbacks} callbacks
128
+ * @returns {Promise<boolean>}
129
+ */
130
+ async create(subscription, callbacks) {
131
+ let subscriptions = this.#subscriptions.get(subscription.origin);
132
+ if (!subscriptions) {
133
+ subscriptions = new Map();
134
+ this.#subscriptions.set(subscription.origin, subscriptions);
135
+ }
136
+ if (subscriptions.has(subscription.id)) return false;
137
+ /** @type {(value?: void) => void} */
138
+ let resolve_acknowledgement = () => {};
139
+ /** @type {(reason?: unknown) => void} */
140
+ let reject_acknowledgement = () => {};
141
+ const acknowledge_promise = new Promise((resolve, reject) => {
142
+ resolve_acknowledgement = resolve;
143
+ reject_acknowledgement = reject;
144
+ });
145
+ /** @type {{ subscription: Subscription, callbacks: SubscriptionCallbacks, queue: Promise<'active' | 'failed'>, close_promise?: Promise<boolean> }} */
146
+ const record = {
147
+ subscription,
148
+ callbacks,
149
+ // Starting behind acknowledgement replaces both active and pending state.
150
+ queue: acknowledge_promise.then(
151
+ () => /** @type {const} */ ('active'),
152
+ () => /** @type {const} */ ('failed'),
153
+ ),
154
+ };
155
+ subscriptions.set(subscription.id, record);
156
+ try {
157
+ Promise.resolve(callbacks.acknowledge()).then(
158
+ resolve_acknowledgement,
159
+ reject_acknowledgement,
160
+ );
161
+ } catch (error) {
162
+ reject_acknowledgement(error);
163
+ }
164
+ try {
165
+ await acknowledge_promise;
166
+ if (record.close_promise) return true;
167
+ await record.queue;
168
+ return true;
169
+ } catch (error) {
170
+ if (!record.close_promise) {
171
+ subscriptions.delete(subscription.id);
172
+ if (subscriptions.size === 0) {
173
+ this.#subscriptions.delete(subscription.origin);
174
+ }
175
+ }
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * @param {JSONRPCRequest} notification
182
+ * @returns {Promise<void>}
183
+ */
184
+ async send(notification) {
185
+ /** @type {Promise<void>[]} */
186
+ const deliveries = [];
187
+ for (const subscriptions of this.#subscriptions.values()) {
188
+ for (const record of subscriptions.values()) {
189
+ if (
190
+ record.close_promise ||
191
+ !matchesSubscription(
192
+ record.subscription.filters,
193
+ notification,
194
+ )
195
+ ) {
196
+ continue;
197
+ }
198
+ deliveries.push(this.#enqueue(record, notification));
199
+ }
200
+ }
201
+ await Promise.all(deliveries);
202
+ }
203
+
204
+ /**
205
+ * @param {string | number} id
206
+ * @param {string} origin
207
+ * @param {'closed' | 'cancelled'} reason
208
+ * @returns {Promise<boolean>}
209
+ */
210
+ async close(id, origin, reason) {
211
+ const subscriptions = this.#subscriptions.get(origin);
212
+ if (!subscriptions) return false;
213
+ const record = subscriptions.get(id);
214
+ if (!record) return false;
215
+ if (record.close_promise) return record.close_promise;
216
+
217
+ record.close_promise = (async () => {
218
+ try {
219
+ await record.queue;
220
+ await record.callbacks.close(reason);
221
+ return true;
222
+ } finally {
223
+ if (subscriptions.get(id) === record) subscriptions.delete(id);
224
+ if (subscriptions.size === 0)
225
+ this.#subscriptions.delete(origin);
226
+ }
227
+ })();
228
+ return record.close_promise;
229
+ }
230
+
231
+ /**
232
+ * @param {string} [origin]
233
+ * @param {'closed' | 'cancelled'} [reason]
234
+ * @returns {Promise<void>}
235
+ */
236
+ async closeAll(origin, reason = 'cancelled') {
237
+ /** @type {Array<[string | number, string]>} */
238
+ const registrations = [];
239
+ for (const [stored_origin, subscriptions] of this.#subscriptions) {
240
+ if (origin !== undefined && stored_origin !== origin) continue;
241
+ for (const id of subscriptions.keys()) {
242
+ registrations.push([id, stored_origin]);
243
+ }
244
+ }
245
+ await Promise.all(
246
+ registrations.map(([id, stored_origin]) =>
247
+ this.close(id, stored_origin, reason),
248
+ ),
249
+ );
250
+ }
251
+ }
252
+
7
253
  /**
8
254
  * @abstract
9
255
  */
@@ -92,7 +338,11 @@ export class InMemoryStreamSessionManager extends StreamSessionManager {
92
338
  send(sessions, data) {
93
339
  for (const [id, controller] of this.#sessions.entries()) {
94
340
  if (sessions == null || sessions.includes(id)) {
95
- controller.enqueue(this.#text_encoder.encode(data));
341
+ try {
342
+ controller.enqueue(this.#text_encoder.encode(data));
343
+ } catch {
344
+ this.#sessions.delete(id);
345
+ }
96
346
  }
97
347
  }
98
348
  }
@@ -301,7 +551,10 @@ export class InMemoryInfoSessionManager extends InfoSessionManager {
301
551
  * @type {InfoSessionManager["delete"]}
302
552
  */
303
553
  delete(id) {
304
- this.#subscriptions.delete(id);
554
+ for (const [uri, subscriptions] of this.#subscriptions) {
555
+ subscriptions.delete(id);
556
+ if (subscriptions.size === 0) this.#subscriptions.delete(uri);
557
+ }
305
558
  this.#log_level.delete(id);
306
559
  this.#client_capabilities.delete(id);
307
560
  this.#client_info.delete(id);
@@ -1,8 +1,55 @@
1
1
  declare module '@tmcp/session-manager' {
2
- import type { Context } from 'tmcp';
2
+ import type { SubscriptionFilter, Context } from 'tmcp';
3
+ import type { JSONRPCRequest } from 'json-rpc-2.0';
3
4
  /**
4
- * @import { Context } from "tmcp";
5
+ * @import { Context, SubscriptionFilter } from "tmcp";
6
+ * @import { JSONRPCRequest } from "json-rpc-2.0";
5
7
  */
8
+ /**
9
+ * Determine whether a notification belongs on a subscription stream.
10
+ * Distributed managers can use this directly or maintain equivalent indexes.
11
+ * */
12
+ export function matchesSubscription(filters: SubscriptionFilter, notification: JSONRPCRequest): boolean;
13
+ /**
14
+ * Routes notifications to long-lived per-request subscription streams.
15
+ * Callbacks remain local to the process serving the response stream. Brokered
16
+ * implementations can distribute notifications without persisting descriptors.
17
+ * @abstract
18
+ */
19
+ export abstract class SubscriptionManager {
20
+ /**
21
+ * @abstract
22
+ * Atomically register a subscription. Matching notifications must be
23
+ * buffered until acknowledgement completes and then delivered in order.
24
+ * */
25
+ abstract create(subscription: Subscription, callbacks: SubscriptionCallbacks): boolean | Promise<boolean>;
26
+ /**
27
+ * @abstract
28
+ * */
29
+ abstract send(notification: JSONRPCRequest): void | Promise<void>;
30
+ /**
31
+ * @abstract
32
+ * Request closure of one subscription. Implementations must preserve the
33
+ * JSON-RPC ID type when identifying a registration.
34
+ * */
35
+ abstract close(id: string | number, origin: string, reason: "closed" | "cancelled"): boolean | Promise<boolean>;
36
+ /**
37
+ * @abstract
38
+ * Close every subscription, optionally limited to one transport origin.
39
+ * */
40
+ abstract closeAll(origin?: string, reason?: "closed" | "cancelled"): void | Promise<void>;
41
+ }
42
+ /**
43
+ * Process-local subscription manager. Distributed implementations should
44
+ * mirror its acknowledgement buffering and per-subscription ordering.
45
+ */
46
+ export class InMemorySubscriptionManager extends SubscriptionManager {
47
+ create(subscription: Subscription, callbacks: SubscriptionCallbacks): Promise<boolean>;
48
+ send(notification: JSONRPCRequest): Promise<void>;
49
+ close(id: string | number, origin: string, reason: "closed" | "cancelled"): Promise<boolean>;
50
+ closeAll(origin?: string, reason?: "closed" | "cancelled"): Promise<void>;
51
+ #private;
52
+ }
6
53
  /**
7
54
  * @abstract
8
55
  */
@@ -79,6 +126,16 @@ declare module '@tmcp/session-manager' {
79
126
  export class InMemoryInfoSessionManager extends InfoSessionManager {
80
127
  #private;
81
128
  }
129
+ export type Subscription = {
130
+ id: string | number;
131
+ origin: string;
132
+ filters: SubscriptionFilter;
133
+ };
134
+ export type SubscriptionCallbacks = {
135
+ acknowledge: () => void | Promise<void>;
136
+ send: (notification: JSONRPCRequest) => void | Promise<void>;
137
+ close: (reason: "closed" | "cancelled") => void | Promise<void>;
138
+ };
82
139
  export {};
83
140
  }
84
141
  //# sourceMappingURL=index.d.ts.map
@@ -2,10 +2,15 @@
2
2
  "version": 3,
3
3
  "file": "index.d.ts",
4
4
  "names": [
5
+ "matchesSubscription",
6
+ "SubscriptionManager",
7
+ "InMemorySubscriptionManager",
5
8
  "StreamSessionManager",
6
9
  "InMemoryStreamSessionManager",
7
10
  "InfoSessionManager",
8
- "InMemoryInfoSessionManager"
11
+ "InMemoryInfoSessionManager",
12
+ "Subscription",
13
+ "SubscriptionCallbacks"
9
14
  ],
10
15
  "sources": [
11
16
  "../index.js"
@@ -13,6 +18,6 @@
13
18
  "sourcesContent": [
14
19
  null
15
20
  ],
16
- "mappings": ";;;;;;;;cASaA,oBAAoBA;;;;;;;;;;;;;;;;;;cAwCpBC,4BAA4BA;;;;;;;;;;;;;;cAsD5BC,kBAAkBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA2FlBC,0BAA0BA",
21
+ "mappings": ";;;;;;;;;;;;iBAkBgBA,mBAAmBA;;;;;;;cA4BtBC,mBAAmBA;;;;;;;;;;;;;;;;;;;;;;;;;;;cA2DnBC,2BAA2BA;;;;;;;;;;;;;;cAsJ3BC,oBAAoBA;;;;;;;;;;;;;;;;;;cAwCpBC,4BAA4BA;;;;;;;;;;;;;;cA0D5BC,kBAAkBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA2FlBC,0BAA0BA;;;aApb4CC,YAAYA;;;;;aAC0FC,qBAAqBA",
17
22
  "ignoreList": []
18
23
  }
package/CHANGELOG.md DELETED
@@ -1,49 +0,0 @@
1
- # @tmcp/session-manager
2
-
3
- ## 0.2.2
4
-
5
- ### Patch Changes
6
-
7
- - 77be8a1: chore: add license
8
-
9
- ## 0.2.1
10
-
11
- ### Patch Changes
12
-
13
- - 5613d43: fix: handle `resources/unsubscribe`
14
- - Updated dependencies [5613d43]
15
- - tmcp@1.16.3
16
-
17
- ## 0.2.0
18
-
19
- ### Minor Changes
20
-
21
- - 8a04ee2: breaking: move sessions out of core into the transports and allow for persistent mcp state
22
-
23
- This release moves the session management out of the core package into the SSE and HTTP transport separately.
24
- While technically a breaking change if you update both `tmcp` and your transport (`@tmcp/transport-http`,
25
- `@tmcp/transport-sse`, or `@tmcp/transport-stdio`), you will not face a breaking change unless you were using a
26
- session manager.
27
-
28
- If you were testing your `McpServer` instance manually you might need to update them to pass the `sessionInfo`
29
- in the context parameter (only if you were reading them in the tool/resource/prompt).
30
-
31
- Sorry for the "breaking" but this was a necessary step to unlock persistent state. 🧡
32
-
33
- ### Patch Changes
34
-
35
- - Updated dependencies [8a04ee2]
36
- - Updated dependencies [a9254cb]
37
- - tmcp@1.16.0
38
-
39
- ## 0.1.2
40
-
41
- ### Patch Changes
42
-
43
- - c0f7d7f: fix: try catch closing controller in case it's already closed
44
-
45
- ## 0.1.1
46
-
47
- ### Patch Changes
48
-
49
- - 957805b: fix: await session manager calls
@@ -1,3 +0,0 @@
1
- import { abstractify } from '@tmcp/internal-ts-helpers';
2
-
3
- abstractify();
package/tsconfig.json DELETED
@@ -1,16 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "esnext",
4
- "module": "nodenext",
5
- "moduleResolution": "nodenext",
6
- "allowJs": true,
7
- "checkJs": true,
8
- "declaration": true,
9
- "emitDeclarationOnly": false,
10
- "strict": true,
11
- "noEmit": true,
12
- "skipLibCheck": true
13
- },
14
- "include": ["src/**/*"],
15
- "exclude": ["node_modules"]
16
- }