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.
- package/dist/channel-tools.js +11 -3
- package/dist/client-bridge.d.ts +2 -0
- package/dist/client-bridge.js +27 -3
- package/dist/manifest-tools.js +32 -5
- package/dist/tenant.d.ts +39 -0
- package/dist/tenant.js +65 -4
- package/dist/types.d.ts +37 -2
- package/dist/ws.js +23 -8
- package/package.json +1 -1
package/dist/channel-tools.js
CHANGED
|
@@ -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.
|
|
38
|
-
|
|
39
|
-
|
|
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 ' +
|
package/dist/client-bridge.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/client-bridge.js
CHANGED
|
@@ -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
|
|
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
|
|
64
|
-
setTimeout(connect,
|
|
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();
|
package/dist/manifest-tools.js
CHANGED
|
@@ -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
|
-
//
|
|
140
|
-
// below - the fixed
|
|
141
|
-
|
|
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
|
|
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
|
|
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'
|
|
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 && !
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
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)'}`);
|