mcp-tenant-lib 0.3.4 → 0.3.6
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 +26 -6
- package/dist/manifest-tools.d.ts +27 -0
- package/dist/manifest-tools.js +52 -25
- package/dist/tenant.d.ts +31 -0
- package/dist/tenant.js +51 -4
- package/dist/types.d.ts +34 -2
- package/dist/ws.js +7 -1
- package/package.json +1 -1
package/dist/channel-tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { tenants, getOrCreateTenant, isValidChannelName } from './tenant.js';
|
|
3
3
|
import { findChannelMatches } from './channel-search.js';
|
|
4
|
+
import { buildDescribePayload } from './manifest-tools.js';
|
|
4
5
|
/**
|
|
5
6
|
* Registers `join_channel` and `list_channels` on an MCP server built via
|
|
6
7
|
* buildMcpServer. Shared across every mcp-tenant-lib consumer (mcp-form,
|
|
@@ -18,7 +19,8 @@ export function registerChannelTools(mcp, tenant, port, setChannel, initialSchem
|
|
|
18
19
|
'anonymous "default" channel, visible to every other unnamed session on this server — skip only when the ' +
|
|
19
20
|
'user says it\'s a one-off/throwaway, or nothing suggests a distinct topic worth naming. Reusing an ' +
|
|
20
21
|
'existing name is expected, not an error: it retargets this session onto that channel\'s live state (e.g. ' +
|
|
21
|
-
'to resume, or redefine/refresh it). Names are URL-safe slugs: letters, digits, underscore, hyphen only.
|
|
22
|
+
'to resume, or redefine/refresh it). Names are URL-safe slugs: letters, digits, underscore, hyphen only. ' +
|
|
23
|
+
'To only inspect a channel\'s tools without retargeting this session onto it, use describe_channel instead.', { channel: z.string().describe('Agent-chosen channel name, e.g. "pets" or "pet_questions_1_of_2". Letters/digits/underscore/hyphen only.') }, async ({ channel }) => {
|
|
22
24
|
if (!isValidChannelName(channel)) {
|
|
23
25
|
return {
|
|
24
26
|
content: [{ type: 'text', text: `Error: "${channel}" is not a valid channel name — use only letters, digits, underscore, and hyphen.` }],
|
|
@@ -32,11 +34,11 @@ export function registerChannelTools(mcp, tenant, port, setChannel, initialSchem
|
|
|
32
34
|
};
|
|
33
35
|
});
|
|
34
36
|
mcp.tool('list_channels', 'Lists every channel currently live on this server, including "default" (the shared, anonymous channel ' +
|
|
35
|
-
'sessions land on before calling join_channel). Use this to discover an existing named channel
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
37
|
+
'sessions land on before calling join_channel). Use this to discover an existing named channel, e.g. when ' +
|
|
38
|
+
'a human refers to "the pets form" without giving the exact channel name. Each entry includes a ' +
|
|
39
|
+
'`connections` array — one item per live browser tab/page bridged into that channel, with its display ' +
|
|
40
|
+
'`label` and `toolCount` — so you can tell which channels actually have something connected. To see the ' +
|
|
41
|
+
'actual tools on one, call describe_channel rather than joining just to look.', {}, async () => {
|
|
40
42
|
const channels = [...tenants.entries()].map(([channel, t]) => ({
|
|
41
43
|
channel,
|
|
42
44
|
connections: [...t.connections.values()].map((c) => ({
|
|
@@ -58,4 +60,22 @@ export function registerChannelTools(mcp, tenant, port, setChannel, initialSchem
|
|
|
58
60
|
const matches = findChannelMatches(query, [...tenants.keys()]);
|
|
59
61
|
return { content: [{ type: 'text', text: JSON.stringify(matches, null, 2) }] };
|
|
60
62
|
});
|
|
63
|
+
mcp.tool('describe_channel', 'Returns the tool manifest for a named channel — same payload as describe_tools, but for ANY channel, ' +
|
|
64
|
+
'not just the one this session is currently on. Use this to go straight from a channel name (e.g. from ' +
|
|
65
|
+
'list_channels or channel_find) to what tools it has, without join_channel first retargeting this ' +
|
|
66
|
+
'session\'s own state onto it. Read-only: does not join, create, or affect this session\'s current ' +
|
|
67
|
+
'channel. Errors if the channel does not exist yet — check list_channels/channel_find first. If a tool ' +
|
|
68
|
+
'listed here fails to invoke with "No such tool available" (typically right after the MCP server ' +
|
|
69
|
+
'process was restarted), your MCP client\'s own connection is stale, not this manifest — tell the user ' +
|
|
70
|
+
'to reconnect the MCP client (e.g. /mcp in Claude Code) rather than retrying the call.', { channel: z.string().describe('Exact channel name, e.g. from list_channels or channel_find.') }, async ({ channel }) => {
|
|
71
|
+
const t = tenants.get(channel);
|
|
72
|
+
if (!t) {
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: 'text', text: `Error: no channel named "${channel}" — use list_channels or channel_find to find the right name.` }],
|
|
75
|
+
isError: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const payload = buildDescribePayload(t);
|
|
79
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
80
|
+
});
|
|
61
81
|
}
|
package/dist/manifest-tools.d.ts
CHANGED
|
@@ -4,6 +4,33 @@ export interface ManifestToolRegistry {
|
|
|
4
4
|
handles: Map<string, RegisteredTool>;
|
|
5
5
|
sync(): void;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Same shape describe_tools returns for the caller's own current channel,
|
|
9
|
+
* built from any Tenant — shared with describe_channel (channel-tools.ts)
|
|
10
|
+
* so a channel's manifest can be inspected by name without join_channel
|
|
11
|
+
* retargeting the session onto it first.
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildDescribePayload<TSchema, TValues>(t: Tenant<TSchema, TValues>): {
|
|
14
|
+
summary: string | null;
|
|
15
|
+
tools: {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
}[];
|
|
19
|
+
connections?: undefined;
|
|
20
|
+
} | {
|
|
21
|
+
connections: {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string | null;
|
|
24
|
+
toolPrefix: string | undefined;
|
|
25
|
+
summary: string | null;
|
|
26
|
+
tools: {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
}[];
|
|
30
|
+
}[];
|
|
31
|
+
summary?: undefined;
|
|
32
|
+
tools?: undefined;
|
|
33
|
+
};
|
|
7
34
|
/**
|
|
8
35
|
* Registers one MCP tool per entry in tenant().toolManifest, dispatching
|
|
9
36
|
* calls to the page via tenant().call(target, args). Also always registers
|
package/dist/manifest-tools.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
class UnsupportedParamTypeError extends Error {
|
|
3
|
+
}
|
|
2
4
|
function paramSpecToZod(spec) {
|
|
3
5
|
let schema;
|
|
4
6
|
switch (spec.type) {
|
|
@@ -11,7 +13,7 @@ function paramSpecToZod(spec) {
|
|
|
11
13
|
case 'boolean':
|
|
12
14
|
schema = z.boolean();
|
|
13
15
|
break;
|
|
14
|
-
default: throw new
|
|
16
|
+
default: throw new UnsupportedParamTypeError(`unsupported param type "${spec.type}" (supported: string, number, boolean)`);
|
|
15
17
|
}
|
|
16
18
|
if (spec.description)
|
|
17
19
|
schema = schema.describe(spec.description);
|
|
@@ -24,6 +26,34 @@ function manifestEntryToZodShape(entry) {
|
|
|
24
26
|
}
|
|
25
27
|
return shape;
|
|
26
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Same shape describe_tools returns for the caller's own current channel,
|
|
31
|
+
* built from any Tenant — shared with describe_channel (channel-tools.ts)
|
|
32
|
+
* so a channel's manifest can be inspected by name without join_channel
|
|
33
|
+
* retargeting the session onto it first.
|
|
34
|
+
*/
|
|
35
|
+
export function buildDescribePayload(t) {
|
|
36
|
+
const conns = [...t.connections.values()];
|
|
37
|
+
if (conns.length <= 1) {
|
|
38
|
+
return {
|
|
39
|
+
summary: t.toolManifestSummary ?? null,
|
|
40
|
+
tools: t.toolManifest.map((e) => ({ name: e.name, description: e.description })),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const slugFor = computeSlugs(conns);
|
|
44
|
+
return {
|
|
45
|
+
connections: conns.map((c) => ({
|
|
46
|
+
id: c.id,
|
|
47
|
+
label: c.label ?? null,
|
|
48
|
+
toolPrefix: slugFor.get(c.id),
|
|
49
|
+
summary: c.summary ?? null,
|
|
50
|
+
tools: c.manifest.map((e) => ({
|
|
51
|
+
name: `${slugFor.get(c.id)}__${e.name}`,
|
|
52
|
+
description: e.description,
|
|
53
|
+
})),
|
|
54
|
+
})),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
27
57
|
const DESCRIBE_TOOLS_NAME = 'describe_tools';
|
|
28
58
|
const IDENTIFY_CONNECTION_NAME = 'identify_connection';
|
|
29
59
|
const IDENTIFY_CONNECTION_DESCRIPTION = 'Pops an alert in the browser tab behind one connection, so a human looking at several open tabs/windows ' +
|
|
@@ -43,7 +73,10 @@ const DESCRIBE_TOOLS_DESCRIPTION = 'Returns manifest-level context for the tools
|
|
|
43
73
|
'IMPORTANT — an empty or unexpected result here does NOT mean no page is bridged: this session may ' +
|
|
44
74
|
'simply be on the wrong channel (see join_channel). If the user expects a specific bridged app/page by ' +
|
|
45
75
|
'name (e.g. "the bulletino tab") and it\'s missing, call list_channels to check for a matching channel ' +
|
|
46
|
-
'and join_channel to it before assuming nothing is connected.'
|
|
76
|
+
'and join_channel to it before assuming nothing is connected. If a tool listed here instead fails to ' +
|
|
77
|
+
'invoke with "No such tool available" (typically right after the MCP server process was restarted), ' +
|
|
78
|
+
'your MCP client\'s own connection is stale, not this manifest — tell the user to reconnect the MCP ' +
|
|
79
|
+
'client (e.g. /mcp in Claude Code) rather than retrying the call.';
|
|
47
80
|
/**
|
|
48
81
|
* Derives a stable, unique tool-name prefix per connection: sanitized from
|
|
49
82
|
* `label` (falling back to "tab" when absent or empty after sanitizing),
|
|
@@ -91,28 +124,7 @@ export function createManifestToolRegistry(mcp, tenant) {
|
|
|
91
124
|
const handles = new Map();
|
|
92
125
|
function registerDescribeTools() {
|
|
93
126
|
const handle = mcp.registerTool(DESCRIBE_TOOLS_NAME, { description: DESCRIBE_TOOLS_DESCRIPTION, inputSchema: {} }, async () => {
|
|
94
|
-
const
|
|
95
|
-
const conns = [...t.connections.values()];
|
|
96
|
-
if (conns.length <= 1) {
|
|
97
|
-
const payload = {
|
|
98
|
-
summary: t.toolManifestSummary ?? null,
|
|
99
|
-
tools: t.toolManifest.map((e) => ({ name: e.name, description: e.description })),
|
|
100
|
-
};
|
|
101
|
-
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
102
|
-
}
|
|
103
|
-
const slugFor = computeSlugs(conns);
|
|
104
|
-
const payload = {
|
|
105
|
-
connections: conns.map((c) => ({
|
|
106
|
-
id: c.id,
|
|
107
|
-
label: c.label ?? null,
|
|
108
|
-
toolPrefix: slugFor.get(c.id),
|
|
109
|
-
summary: c.summary ?? null,
|
|
110
|
-
tools: c.manifest.map((e) => ({
|
|
111
|
-
name: `${slugFor.get(c.id)}__${e.name}`,
|
|
112
|
-
description: e.description,
|
|
113
|
-
})),
|
|
114
|
-
})),
|
|
115
|
-
};
|
|
127
|
+
const payload = buildDescribePayload(tenant());
|
|
116
128
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
117
129
|
});
|
|
118
130
|
handles.set(DESCRIBE_TOOLS_NAME, handle);
|
|
@@ -177,7 +189,22 @@ export function createManifestToolRegistry(mcp, tenant) {
|
|
|
177
189
|
for (const [registeredName, { connectionId, entry }] of registeredNow) {
|
|
178
190
|
if (handles.has(registeredName))
|
|
179
191
|
continue;
|
|
180
|
-
|
|
192
|
+
let inputSchema;
|
|
193
|
+
try {
|
|
194
|
+
inputSchema = manifestEntryToZodShape(entry);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
// A single page-authored tool with a malformed param spec (bad/missing
|
|
198
|
+
// `type`) must not take down sync() for every other tool on the
|
|
199
|
+
// channel, nor crash whatever triggered this sync (e.g. join_channel
|
|
200
|
+
// migrating the registry) — skip just this one entry.
|
|
201
|
+
if (err instanceof UnsupportedParamTypeError) {
|
|
202
|
+
console.error(`[mcp-tenant-lib] skipping tool "${registeredName}": ${err.message}`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
const handle = mcp.registerTool(registeredName, { description: entry.description, inputSchema }, async (args) => {
|
|
181
208
|
try {
|
|
182
209
|
const result = await tenant().call(connectionId, entry.name, args);
|
|
183
210
|
return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
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;
|
|
@@ -139,6 +149,27 @@ export declare class Tenant<TSchema, TValues> {
|
|
|
139
149
|
rejectCall(id: string, error: string): void;
|
|
140
150
|
touch(): void;
|
|
141
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;
|
|
142
173
|
broadcastReinit(): void;
|
|
143
174
|
broadcastUpdate(field: string, value: unknown): void;
|
|
144
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
|
|
@@ -292,14 +302,51 @@ export class Tenant {
|
|
|
292
302
|
touch() {
|
|
293
303
|
this.lastActivityAt = Date.now();
|
|
294
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
|
+
}
|
|
295
312
|
applyState(schema, values) {
|
|
296
313
|
this.store.dispose();
|
|
297
314
|
this.schema = schema;
|
|
298
|
-
this
|
|
315
|
+
this.#attachStore(new Store(values));
|
|
299
316
|
this.submitted = false;
|
|
300
|
-
this.store.onChange((field, value) => this.broadcastUpdate(field, value));
|
|
301
317
|
this.broadcastReinit();
|
|
302
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
|
+
}
|
|
303
350
|
broadcastReinit() {
|
|
304
351
|
const payload = JSON.stringify({ type: 'reinit', schema: this.schema, state: this.store.snapshot(), waiting: this.waiting, submitted: this.submitted });
|
|
305
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;
|
|
@@ -25,6 +32,31 @@ export interface SetMessage {
|
|
|
25
32
|
field: string;
|
|
26
33
|
value: unknown;
|
|
27
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
|
+
}
|
|
28
60
|
export interface SubmitMessage {
|
|
29
61
|
type: 'submit';
|
|
30
62
|
}
|
|
@@ -93,4 +125,4 @@ export interface RenameConnectionMessage {
|
|
|
93
125
|
type: 'rename_connection';
|
|
94
126
|
appLabel: string;
|
|
95
127
|
}
|
|
96
|
-
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
|
@@ -60,7 +60,7 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
|
|
|
60
60
|
if (state)
|
|
61
61
|
state.isAlive = true;
|
|
62
62
|
});
|
|
63
|
-
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 }));
|
|
64
64
|
ws.on('message', (raw) => {
|
|
65
65
|
t.touch();
|
|
66
66
|
let msg;
|
|
@@ -92,6 +92,12 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
|
|
|
92
92
|
else
|
|
93
93
|
t.resolveCall(msg.id, msg.result);
|
|
94
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
|
+
}
|
|
95
101
|
});
|
|
96
102
|
ws.on('close', (code, reason) => {
|
|
97
103
|
console.error(`[ws] connection closed: tenant=${tenantId} connection=${connectionId} code=${code} reason=${reason.toString() || '(none)'}`);
|