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.
@@ -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.', { 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
+ '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 before ' +
36
- 'calling join_channel on it, e.g. when a human refers to "the pets form" without giving the exact channel ' +
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 () => {
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
  }
@@ -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
@@ -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 Error(`unsupported param type "${spec.type}" (supported: string, number, boolean)`);
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 t = tenant();
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
- const handle = mcp.registerTool(registeredName, { description: entry.description, inputSchema: manifestEntryToZodShape(entry) }, async (args) => {
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.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
@@ -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.store = new Store(values);
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' | '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;
@@ -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)'}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-tenant-lib",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "description": "Generic tenant/session bookkeeping + MCP wiring, reusable across projects.",
6
6
  "repository": {