mcp-tenant-lib 0.3.3 → 0.3.4

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
@@ -127,6 +127,14 @@ export declare class Tenant<TSchema, TValues> {
127
127
  * self-contained and cheap for the rare/short-lived window it's used in.
128
128
  */
129
129
  private waitForReconnect;
130
+ /**
131
+ * Pushes an 'identify' message at one connection so its page can surface
132
+ * something a human watching the browser will notice (alert/sound) — lets
133
+ * an agent say "which tab is this" when several are bridged into one
134
+ * channel. Fire-and-forget like rename_connection: no ack, and silently
135
+ * a no-op if the connection has since closed.
136
+ */
137
+ identifyConnection(connectionId: string): boolean;
130
138
  resolveCall(id: string, result: unknown): void;
131
139
  rejectCall(id: string, error: string): void;
132
140
  touch(): void;
package/dist/tenant.js CHANGED
@@ -261,6 +261,20 @@ export class Tenant {
261
261
  poll();
262
262
  });
263
263
  }
264
+ /**
265
+ * Pushes an 'identify' message at one connection so its page can surface
266
+ * something a human watching the browser will notice (alert/sound) — lets
267
+ * an agent say "which tab is this" when several are bridged into one
268
+ * channel. Fire-and-forget like rename_connection: no ack, and silently
269
+ * a no-op if the connection has since closed.
270
+ */
271
+ identifyConnection(connectionId) {
272
+ const conn = this.connections.get(connectionId);
273
+ if (!conn || conn.socket.readyState !== conn.socket.OPEN)
274
+ return false;
275
+ conn.socket.send(JSON.stringify({ type: 'identify', label: conn.label }));
276
+ return true;
277
+ }
264
278
  resolveCall(id, result) {
265
279
  const pending = this.pendingCalls.get(id);
266
280
  if (!pending)
package/dist/types.d.ts CHANGED
@@ -16,6 +16,9 @@ export type ServerMessage<TSchema = unknown, TValues = unknown> = {
16
16
  } | {
17
17
  type: 'waiting';
18
18
  waiting: boolean;
19
+ } | {
20
+ type: 'identify';
21
+ label?: string;
19
22
  } | CallMessage;
20
23
  export interface SetMessage {
21
24
  type: 'set';
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)`);
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.4",
4
4
  "type": "module",
5
5
  "description": "Generic tenant/session bookkeeping + MCP wiring, reusable across projects.",
6
6
  "repository": {