mcp-tenant-lib 0.3.3 → 0.3.5

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.
@@ -34,9 +34,17 @@ export function registerChannelTools(mcp, tenant, port, setChannel, initialSchem
34
34
  mcp.tool('list_channels', 'Lists every channel currently live on this server, including "default" (the shared, anonymous channel ' +
35
35
  'sessions land on before calling join_channel). Use this to discover an existing named channel before ' +
36
36
  'calling join_channel on it, e.g. when a human refers to "the pets form" without giving the exact channel ' +
37
- 'name.', {}, async () => {
38
- const ids = [...tenants.keys()];
39
- return { content: [{ type: 'text', text: JSON.stringify(ids, null, 2) }] };
37
+ 'name. Each entry includes a `connections` array — one item per live browser tab/page bridged into that ' +
38
+ 'channel, with its display `label` and `toolCount` — so you can tell which channels actually have ' +
39
+ 'something connected without joining each one to check.', {}, async () => {
40
+ const channels = [...tenants.entries()].map(([channel, t]) => ({
41
+ channel,
42
+ connections: [...t.connections.values()].map((c) => ({
43
+ label: c.label ?? null,
44
+ toolCount: c.manifest.length,
45
+ })),
46
+ }));
47
+ return { content: [{ type: 'text', text: JSON.stringify(channels, null, 2) }] };
40
48
  });
41
49
  mcp.tool('channel_find', 'Fuzzy-searches existing channel names for ones matching a loose query — use this when a human refers to ' +
42
50
  'a channel by topic or partial name (e.g. "the pets channel") rather than its exact name, instead of ' +
@@ -36,6 +36,8 @@ export interface StateSocketHandlers<TSchema, TValues> {
36
36
  onCall?(id: string, name: string, args: unknown): void;
37
37
  onConnect?(): void;
38
38
  onDisconnect?(): void;
39
+ /** Server-pushed "identify yourself" signal (see identify_connection tool). Defaults to a window.alert(). */
40
+ onIdentify?(label: string | undefined): void;
39
41
  }
40
42
  export interface StateSocketOptions {
41
43
  /**
@@ -30,10 +30,22 @@ export function createClientBridge(actions) {
30
30
  },
31
31
  };
32
32
  }
33
+ /** Steady-state delay between reconnect attempts once the connection drops. */
34
+ const RECONNECT_INTERVAL_MS = 10_000;
35
+ /**
36
+ * How long to keep retrying at RECONNECT_INTERVAL_MS before giving up
37
+ * entirely. A dropped connection is usually the server restarting (or a
38
+ * laptop sleeping) rather than something permanent, and unknown named
39
+ * tenants are now recreated on demand server-side (see ws.ts) rather than
40
+ * rejected — so it's worth retrying for a long while rather than a few
41
+ * seconds, without retrying literally forever if the server is gone for good.
42
+ */
43
+ const RECONNECT_GIVE_UP_MS = 60 * 60 * 1000;
33
44
  export function connectStateSocket(handlers, options = {}) {
34
45
  let ws;
35
46
  let closedByCaller = false;
36
47
  let reconnectAttempts = 0;
48
+ let firstDisconnectAt;
37
49
  const connect = () => {
38
50
  const tenantId = options.tenant ?? (location.pathname.startsWith('/t/')
39
51
  ? location.pathname.slice('/t/'.length).split('/')[0]
@@ -48,6 +60,7 @@ export function connectStateSocket(handlers, options = {}) {
48
60
  ws.onopen = () => {
49
61
  console.log(`[mcp-ws] connected${reconnectAttempts > 0 ? ` after ${reconnectAttempts} reconnect attempt(s)` : ''}`);
50
62
  reconnectAttempts = 0;
63
+ firstDisconnectAt = undefined;
51
64
  handlers.onConnect?.();
52
65
  };
53
66
  ws.onclose = (event) => {
@@ -56,12 +69,17 @@ export function connectStateSocket(handlers, options = {}) {
56
69
  if (closedByCaller)
57
70
  return;
58
71
  if (event.code === 4404) {
59
- console.log('[mcp-ws] tenant unknown/expired (4404) — not retrying');
72
+ console.log('[mcp-ws] invalid tenant id (4404) — not retrying');
73
+ return;
74
+ }
75
+ firstDisconnectAt ??= Date.now();
76
+ if (Date.now() - firstDisconnectAt > RECONNECT_GIVE_UP_MS) {
77
+ console.log(`[mcp-ws] giving up after retrying for over ${RECONNECT_GIVE_UP_MS / 60_000} minutes`);
60
78
  return;
61
79
  }
62
80
  reconnectAttempts++;
63
- console.log(`[mcp-ws] retrying in 2s (attempt ${reconnectAttempts + 1})`);
64
- setTimeout(connect, 2000);
81
+ console.log(`[mcp-ws] retrying in ${RECONNECT_INTERVAL_MS / 1000}s (attempt ${reconnectAttempts + 1})`);
82
+ setTimeout(connect, RECONNECT_INTERVAL_MS);
65
83
  };
66
84
  ws.onerror = () => {
67
85
  console.log('[mcp-ws] socket error (see close event for details)');
@@ -76,6 +94,12 @@ export function connectStateSocket(handlers, options = {}) {
76
94
  handlers.onUpdate?.(msg.field, msg.value);
77
95
  if (msg.type === 'call')
78
96
  handlers.onCall?.(msg.id, msg.name, msg.args);
97
+ if (msg.type === 'identify') {
98
+ if (handlers.onIdentify)
99
+ handlers.onIdentify(msg.label);
100
+ else
101
+ alert(`Identify: this is the "${msg.label ?? 'unlabeled'}" connection`);
102
+ }
79
103
  };
80
104
  };
81
105
  connect();
@@ -25,6 +25,12 @@ function manifestEntryToZodShape(entry) {
25
25
  return shape;
26
26
  }
27
27
  const DESCRIBE_TOOLS_NAME = 'describe_tools';
28
+ const IDENTIFY_CONNECTION_NAME = 'identify_connection';
29
+ const IDENTIFY_CONNECTION_DESCRIPTION = 'Pops an alert in the browser tab behind one connection, so a human looking at several open tabs/windows ' +
30
+ 'can tell which one this session means. Use when the user has multiple tabs bridged in and asks "which one ' +
31
+ 'is X" or you need them to look at a specific one. Pass the connection `id` from describe_tools\' ' +
32
+ '`connections` array (single-connection channels can omit it). Fire-and-forget: returns immediately, does ' +
33
+ 'not confirm the human saw it.';
28
34
  const DESCRIBE_TOOLS_DESCRIPTION = 'Returns manifest-level context for the tools connected to THIS SESSION\'S CURRENT CHANNEL: a ' +
29
35
  'page-authored summary (what kind of page/app this is, cross-tool sequencing rules, ' +
30
36
  'domain concepts) plus the current list of tool names and one-line descriptions. Call ' +
@@ -111,6 +117,24 @@ export function createManifestToolRegistry(mcp, tenant) {
111
117
  });
112
118
  handles.set(DESCRIBE_TOOLS_NAME, handle);
113
119
  }
120
+ function registerIdentifyConnection() {
121
+ const handle = mcp.registerTool(IDENTIFY_CONNECTION_NAME, {
122
+ description: IDENTIFY_CONNECTION_DESCRIPTION,
123
+ inputSchema: { id: z.string().optional().describe('Connection id from describe_tools\' `connections` array. Omit when only one connection is live.') },
124
+ }, async ({ id }) => {
125
+ const t = tenant();
126
+ const targetId = id ?? [...t.connections.keys()][0];
127
+ if (!targetId) {
128
+ return { content: [{ type: 'text', text: 'No live connection on this channel to identify.' }], isError: true };
129
+ }
130
+ const ok = t.identifyConnection(targetId);
131
+ return {
132
+ content: [{ type: 'text', text: ok ? `Identify signal sent to connection "${targetId}".` : `Connection "${targetId}" is not currently open.` }],
133
+ isError: !ok,
134
+ };
135
+ });
136
+ handles.set(IDENTIFY_CONNECTION_NAME, handle);
137
+ }
114
138
  function sync() {
115
139
  const conns = [...tenant().connections.values()];
116
140
  const multi = conns.length >= 2;
@@ -122,7 +146,7 @@ export function createManifestToolRegistry(mcp, tenant) {
122
146
  if (multi) {
123
147
  for (const conn of conns) {
124
148
  for (const entry of conn.manifest) {
125
- if (entry.name === DESCRIBE_TOOLS_NAME)
149
+ if (entry.name === DESCRIBE_TOOLS_NAME || entry.name === IDENTIFY_CONNECTION_NAME)
126
150
  continue;
127
151
  registeredNow.set(`${slugFor.get(conn.id)}__${entry.name}`, { connectionId: conn.id, entry });
128
152
  }
@@ -131,14 +155,15 @@ export function createManifestToolRegistry(mcp, tenant) {
131
155
  else {
132
156
  const conn = conns[0];
133
157
  for (const entry of tenant().toolManifest) {
134
- if (entry.name === DESCRIBE_TOOLS_NAME)
158
+ if (entry.name === DESCRIBE_TOOLS_NAME || entry.name === IDENTIFY_CONNECTION_NAME)
135
159
  continue;
136
160
  registeredNow.set(entry.name, { connectionId: conn?.id, entry });
137
161
  }
138
162
  }
139
- // A page tool named "describe_tools" would collide with the fixed tool
140
- // below - the fixed one always wins so agents can rely on the name.
141
- const currentNames = new Set([DESCRIBE_TOOLS_NAME, ...registeredNow.keys()]);
163
+ // Page tools named "describe_tools"/"identify_connection" would collide
164
+ // with the fixed tools below - the fixed ones always win so agents can
165
+ // rely on the name.
166
+ const currentNames = new Set([DESCRIBE_TOOLS_NAME, IDENTIFY_CONNECTION_NAME, ...registeredNow.keys()]);
142
167
  for (const [name, handle] of handles) {
143
168
  if (!currentNames.has(name)) {
144
169
  handle.remove();
@@ -147,6 +172,8 @@ export function createManifestToolRegistry(mcp, tenant) {
147
172
  }
148
173
  if (!handles.has(DESCRIBE_TOOLS_NAME))
149
174
  registerDescribeTools();
175
+ if (!handles.has(IDENTIFY_CONNECTION_NAME))
176
+ registerIdentifyConnection();
150
177
  for (const [registeredName, { connectionId, entry }] of registeredNow) {
151
178
  if (handles.has(registeredName))
152
179
  continue;
package/dist/tenant.d.ts CHANGED
@@ -56,6 +56,16 @@ export declare class Tenant<TSchema, TValues> {
56
56
  * list_fields read should be able to see that they're done.
57
57
  */
58
58
  submitted: boolean;
59
+ /**
60
+ * Timestamp of the most recent real edit to `store` (a field value
61
+ * changing) or a successfully-applied `restoreState` (see below) —
62
+ * distinct from `lastActivityAt`, which also counts reads/pings and
63
+ * drives idle-sweep instead. Used solely to arbitrate `resync` messages
64
+ * (ws.ts) when more than one browser tab reconnects to the same
65
+ * recreated tenant: whichever tab's last edit is newer wins, rather than
66
+ * whichever tab's resync happens to reach the server first.
67
+ */
68
+ lastStateChangeAt: number;
59
69
  wsClients: Set<WebSocket>;
60
70
  connections: Map<string, TenantConnection>;
61
71
  lastActivityAt: number;
@@ -127,10 +137,39 @@ export declare class Tenant<TSchema, TValues> {
127
137
  * self-contained and cheap for the rare/short-lived window it's used in.
128
138
  */
129
139
  private waitForReconnect;
140
+ /**
141
+ * Pushes an 'identify' message at one connection so its page can surface
142
+ * something a human watching the browser will notice (alert/sound) — lets
143
+ * an agent say "which tab is this" when several are bridged into one
144
+ * channel. Fire-and-forget like rename_connection: no ack, and silently
145
+ * a no-op if the connection has since closed.
146
+ */
147
+ identifyConnection(connectionId: string): boolean;
130
148
  resolveCall(id: string, result: unknown): void;
131
149
  rejectCall(id: string, error: string): void;
132
150
  touch(): void;
133
151
  applyState(schema: TSchema, values: TValues): void;
152
+ /**
153
+ * Restores schema/values/submitted from a browser page's own live state
154
+ * after this tenant was recreated empty (see the `recreated` init flag in
155
+ * ws.ts) — unlike applyState (used by define_form to redefine a form),
156
+ * this preserves `submitted` as reported by the page rather than always
157
+ * resetting it, since a resync is recovering prior state, not starting a
158
+ * new round.
159
+ *
160
+ * `changedAt` is the pushing page's own last-local-edit timestamp
161
+ * (Date.now() when the user or agent last changed a field in that tab —
162
+ * see mcp-form's client). When two tabs both reconnect to the same
163
+ * recreated tenant, each pushes its own resync independently; without
164
+ * this guard, whichever happens to reach the server first would win,
165
+ * which has nothing to do with which tab actually has the fresher data.
166
+ * Comparing against `lastStateChangeAt` (bumped by every real edit,
167
+ * including a previously-applied resync) means an older resync arriving
168
+ * after a newer one — or after the tenant was already touched some other
169
+ * way, e.g. an agent's set_field — is ignored rather than clobbering it.
170
+ * Returns whether the resync was applied.
171
+ */
172
+ restoreState(schema: TSchema, values: TValues, submitted: boolean, changedAt: number): boolean;
134
173
  broadcastReinit(): void;
135
174
  broadcastUpdate(field: string, value: unknown): void;
136
175
  dispose(): void;
package/dist/tenant.js CHANGED
@@ -59,6 +59,16 @@ export class Tenant {
59
59
  * list_fields read should be able to see that they're done.
60
60
  */
61
61
  submitted = false;
62
+ /**
63
+ * Timestamp of the most recent real edit to `store` (a field value
64
+ * changing) or a successfully-applied `restoreState` (see below) —
65
+ * distinct from `lastActivityAt`, which also counts reads/pings and
66
+ * drives idle-sweep instead. Used solely to arbitrate `resync` messages
67
+ * (ws.ts) when more than one browser tab reconnects to the same
68
+ * recreated tenant: whichever tab's last edit is newer wins, rather than
69
+ * whichever tab's resync happens to reach the server first.
70
+ */
71
+ lastStateChangeAt = 0;
62
72
  wsClients;
63
73
  connections = new Map();
64
74
  #legacyManifest;
@@ -102,7 +112,7 @@ export class Tenant {
102
112
  constructor(id, initialSchema, initialValues) {
103
113
  this.id = id;
104
114
  this.schema = initialSchema;
105
- this.store = new Store(initialValues);
115
+ this.store = new Store(initialValues); // placeholder; #attachStore below wires the real onChange listener
106
116
  this.submitBus = new EventEmitter();
107
117
  this.submitBus.setMaxListeners(0);
108
118
  this.submitBus.on('newListener', (event) => {
@@ -117,7 +127,7 @@ export class Tenant {
117
127
  });
118
128
  this.wsClients = new Set();
119
129
  this.lastActivityAt = Date.now();
120
- this.store.onChange((field, value) => this.broadcastUpdate(field, value));
130
+ this.#attachStore(this.store);
121
131
  }
122
132
  /**
123
133
  * Legacy/no-WS path: registers a manifest with no real connection behind
@@ -261,6 +271,20 @@ export class Tenant {
261
271
  poll();
262
272
  });
263
273
  }
274
+ /**
275
+ * Pushes an 'identify' message at one connection so its page can surface
276
+ * something a human watching the browser will notice (alert/sound) — lets
277
+ * an agent say "which tab is this" when several are bridged into one
278
+ * channel. Fire-and-forget like rename_connection: no ack, and silently
279
+ * a no-op if the connection has since closed.
280
+ */
281
+ identifyConnection(connectionId) {
282
+ const conn = this.connections.get(connectionId);
283
+ if (!conn || conn.socket.readyState !== conn.socket.OPEN)
284
+ return false;
285
+ conn.socket.send(JSON.stringify({ type: 'identify', label: conn.label }));
286
+ return true;
287
+ }
264
288
  resolveCall(id, result) {
265
289
  const pending = this.pendingCalls.get(id);
266
290
  if (!pending)
@@ -278,14 +302,51 @@ export class Tenant {
278
302
  touch() {
279
303
  this.lastActivityAt = Date.now();
280
304
  }
305
+ #attachStore(store) {
306
+ this.store = store;
307
+ this.store.onChange((field, value) => {
308
+ this.lastStateChangeAt = Date.now();
309
+ this.broadcastUpdate(field, value);
310
+ });
311
+ }
281
312
  applyState(schema, values) {
282
313
  this.store.dispose();
283
314
  this.schema = schema;
284
- this.store = new Store(values);
315
+ this.#attachStore(new Store(values));
285
316
  this.submitted = false;
286
- this.store.onChange((field, value) => this.broadcastUpdate(field, value));
287
317
  this.broadcastReinit();
288
318
  }
319
+ /**
320
+ * Restores schema/values/submitted from a browser page's own live state
321
+ * after this tenant was recreated empty (see the `recreated` init flag in
322
+ * ws.ts) — unlike applyState (used by define_form to redefine a form),
323
+ * this preserves `submitted` as reported by the page rather than always
324
+ * resetting it, since a resync is recovering prior state, not starting a
325
+ * new round.
326
+ *
327
+ * `changedAt` is the pushing page's own last-local-edit timestamp
328
+ * (Date.now() when the user or agent last changed a field in that tab —
329
+ * see mcp-form's client). When two tabs both reconnect to the same
330
+ * recreated tenant, each pushes its own resync independently; without
331
+ * this guard, whichever happens to reach the server first would win,
332
+ * which has nothing to do with which tab actually has the fresher data.
333
+ * Comparing against `lastStateChangeAt` (bumped by every real edit,
334
+ * including a previously-applied resync) means an older resync arriving
335
+ * after a newer one — or after the tenant was already touched some other
336
+ * way, e.g. an agent's set_field — is ignored rather than clobbering it.
337
+ * Returns whether the resync was applied.
338
+ */
339
+ restoreState(schema, values, submitted, changedAt) {
340
+ if (changedAt < this.lastStateChangeAt)
341
+ return false;
342
+ this.store.dispose();
343
+ this.schema = schema;
344
+ this.#attachStore(new Store(values));
345
+ this.submitted = submitted;
346
+ this.lastStateChangeAt = changedAt;
347
+ this.broadcastReinit();
348
+ return true;
349
+ }
289
350
  broadcastReinit() {
290
351
  const payload = JSON.stringify({ type: 'reinit', schema: this.schema, state: this.store.snapshot(), waiting: this.waiting, submitted: this.submitted });
291
352
  for (const client of this.wsClients) {
package/dist/types.d.ts CHANGED
@@ -4,7 +4,14 @@ export interface SubmitPayload {
4
4
  [field: string]: unknown;
5
5
  }
6
6
  export type ServerMessage<TSchema = unknown, TValues = unknown> = {
7
- type: 'init' | 'reinit';
7
+ type: 'init';
8
+ schema: TSchema;
9
+ state: TValues;
10
+ waiting: boolean;
11
+ submitted: boolean;
12
+ recreated: boolean;
13
+ } | {
14
+ type: 'reinit';
8
15
  schema: TSchema;
9
16
  state: TValues;
10
17
  waiting: boolean;
@@ -16,12 +23,40 @@ export type ServerMessage<TSchema = unknown, TValues = unknown> = {
16
23
  } | {
17
24
  type: 'waiting';
18
25
  waiting: boolean;
26
+ } | {
27
+ type: 'identify';
28
+ label?: string;
19
29
  } | CallMessage;
20
30
  export interface SetMessage {
21
31
  type: 'set';
22
32
  field: string;
23
33
  value: unknown;
24
34
  }
35
+ /**
36
+ * Sent by a page that reconnects to a tenant the server reports as
37
+ * `recreated` (see the `init` message) — i.e. the in-memory tenant was
38
+ * lost, most commonly an MCP server restart, while this page's own JS
39
+ * runtime (and therefore its last-known schema/values) survived because
40
+ * the tab itself never reloaded. Lets the still-live browser page push its
41
+ * state back up as the source of truth instead of accepting the server's
42
+ * freshly-recreated default state. The server applies it via the same
43
+ * path as define_form (Tenant.applyState) and rebroadcasts `reinit` to any
44
+ * other connected tabs.
45
+ */
46
+ export interface ResyncMessage<TSchema = unknown, TValues = unknown> {
47
+ type: 'resync';
48
+ schema: TSchema;
49
+ values: TValues;
50
+ submitted: boolean;
51
+ /**
52
+ * The pushing page's own last-local-edit timestamp (Date.now()) — lets
53
+ * the server arbitrate when two tabs both resync the same recreated
54
+ * tenant, favoring whichever has the more recently edited data rather
55
+ * than whichever resync message happens to arrive first. See
56
+ * Tenant.restoreState in mcp-tenant-lib for the comparison.
57
+ */
58
+ changedAt: number;
59
+ }
25
60
  export interface SubmitMessage {
26
61
  type: 'submit';
27
62
  }
@@ -90,4 +125,4 @@ export interface RenameConnectionMessage {
90
125
  type: 'rename_connection';
91
126
  appLabel: string;
92
127
  }
93
- export type ClientMessage = SetMessage | SubmitMessage | InterruptMessage | RegisterToolsMessage | CallResultMessage | RenameConnectionMessage;
128
+ export type ClientMessage = SetMessage | SubmitMessage | InterruptMessage | RegisterToolsMessage | CallResultMessage | RenameConnectionMessage | ResyncMessage;
package/dist/ws.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { WebSocketServer } from 'ws';
3
- import { getOrCreateTenant, tenants } from './tenant.js';
3
+ import { getOrCreateTenant, tenants, isValidChannelName } from './tenant.js';
4
4
  /**
5
5
  * Ping interval for the liveness check below. Half-open sockets (client
6
6
  * process killed/suspended without a clean TCP close — the common case for
@@ -32,16 +32,25 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
32
32
  wss.on('connection', (ws, req) => {
33
33
  const wsUrl = new URL(req.url ?? '/', `http://localhost:${port}`);
34
34
  const requestedTenantId = wsUrl.searchParams.get('tenant');
35
- if (requestedTenantId && !tenants.has(requestedTenantId)) {
36
- // An explicit tenant was requested but no longer exists (for example
37
- // because its MCP session was disposed). Reject instead of silently
38
- // falling back to the shared default tenant.
39
- console.error(`[ws] rejected connection: unknown/expired tenant "${requestedTenantId}"`);
40
- ws.close(4404, 'Unknown or expired tenant');
35
+ if (requestedTenantId && !isValidChannelName(requestedTenantId)) {
36
+ // Never silently vivify a channel from a malformed/hostile id same
37
+ // validation join_channel applies agent-side.
38
+ console.error(`[ws] rejected connection: invalid tenant id "${requestedTenantId}"`);
39
+ ws.close(4404, 'Invalid tenant id');
41
40
  return;
42
41
  }
43
42
  const tenantId = requestedTenantId || 'default';
43
+ const recreated = !!requestedTenantId && !tenants.has(requestedTenantId);
44
44
  const t = getOrCreateTenant(tenantId, initialSchema, initialValues);
45
+ if (recreated) {
46
+ // A page reconnecting (browser retry loop) to a named channel that no
47
+ // longer exists server-side — most commonly a server restart, which
48
+ // wipes the in-memory tenants map entirely. Recreate it on demand
49
+ // rather than rejecting, so the page keeps working and an agent can
50
+ // rejoin the same name later instead of the connection being stuck
51
+ // retrying forever against a channel the server will never revive.
52
+ console.error(`[ws] recreated previously unknown/expired tenant "${tenantId}" on reconnect`);
53
+ }
45
54
  const connectionId = randomUUID();
46
55
  t.registerConnection(connectionId, ws);
47
56
  console.error(`[ws] connection opened: tenant=${tenantId} connection=${connectionId} (${t.connections.size} connection(s) on tenant)`);
@@ -51,7 +60,7 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
51
60
  if (state)
52
61
  state.isAlive = true;
53
62
  });
54
- ws.send(JSON.stringify({ type: 'init', schema: t.schema, state: t.store.snapshot(), waiting: t.waiting, submitted: t.submitted }));
63
+ ws.send(JSON.stringify({ type: 'init', schema: t.schema, state: t.store.snapshot(), waiting: t.waiting, submitted: t.submitted, recreated }));
55
64
  ws.on('message', (raw) => {
56
65
  t.touch();
57
66
  let msg;
@@ -83,6 +92,12 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
83
92
  else
84
93
  t.resolveCall(msg.id, msg.result);
85
94
  }
95
+ if (msg.type === 'resync') {
96
+ const applied = t.restoreState(msg.schema, msg.values, msg.submitted, msg.changedAt);
97
+ console.error(applied
98
+ ? `[ws] resync from connection=${connectionId}: restoring tenant "${tenantId}" state pushed back by the browser`
99
+ : `[ws] resync from connection=${connectionId}: ignored — tenant "${tenantId}" already has state at least as recent`);
100
+ }
86
101
  });
87
102
  ws.on('close', (code, reason) => {
88
103
  console.error(`[ws] connection closed: tenant=${tenantId} connection=${connectionId} code=${code} reason=${reason.toString() || '(none)'}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-tenant-lib",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "description": "Generic tenant/session bookkeeping + MCP wiring, reusable across projects.",
6
6
  "repository": {