mcp-tenant-lib 0.1.2 → 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.
@@ -0,0 +1,17 @@
1
+ export interface ChannelMatch {
2
+ name: string;
3
+ score: number;
4
+ }
5
+ /**
6
+ * Scores one candidate channel name against a query, case-insensitively:
7
+ * the best of (a) whole-string typo tolerance, (b) whole-string stem match,
8
+ * and (c) the best per-word score against the candidate's underscore/
9
+ * hyphen-split words (catches "pets" -> "pet_food_memory" via "pet", and
10
+ * "pests" -> "pets_discussion" via a length-gated typo match on "pets").
11
+ */
12
+ export declare function scoreChannelMatch(query: string, candidate: string): number;
13
+ /**
14
+ * Ranks every candidate against the query, highest score first, keeping
15
+ * only matches above `minScore` (default 0.5).
16
+ */
17
+ export declare function findChannelMatches(query: string, candidates: string[], minScore?: number): ChannelMatch[];
@@ -0,0 +1,80 @@
1
+ import { distance } from 'fastest-levenshtein';
2
+ /**
3
+ * Splits a channel name into its underscore/hyphen-delimited words, e.g.
4
+ * "pet_food_memory" -> ["pet", "food", "memory"]. Used so a query like
5
+ * "pets" can match "pet_food_memory" via the shared word "pet" even though
6
+ * the two full strings have a large edit distance overall.
7
+ */
8
+ function words(name) {
9
+ return name.split(/[_-]+/).filter(Boolean);
10
+ }
11
+ /**
12
+ * Typo-tolerance score for two strings of similar length/spelling — 1 for
13
+ * identical, decreasing with edit distance relative to length. This is
14
+ * deliberately NOT used to compare unrelated short words against each other
15
+ * (see stemScore) — two coincidentally-similar-length words like "pets" and
16
+ * "prefs" have a small edit distance purely by chance, which this alone
17
+ * cannot tell apart from an actual typo of the same word.
18
+ */
19
+ function typoScore(a, b) {
20
+ const maxLen = Math.max(a.length, b.length);
21
+ if (maxLen === 0)
22
+ return 1;
23
+ return 1 - distance(a, b) / maxLen;
24
+ }
25
+ /**
26
+ * Stem/prefix match score: how much of the shorter string is a literal
27
+ * prefix of the longer one, e.g. "pet" is a full-length prefix of "pets" ->
28
+ * strong match. Zero if neither is a prefix of the other at all — this is
29
+ * an intentionally strict, high-precision signal (no edit-distance fuzz),
30
+ * so it doesn't fire on coincidences the way typoScore can.
31
+ */
32
+ function stemScore(a, b) {
33
+ const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
34
+ if (shorter.length < 3 || !longer.startsWith(shorter))
35
+ return 0;
36
+ return shorter.length / longer.length;
37
+ }
38
+ /**
39
+ * Per-word score: the best of a strict stem match (see stemScore) and a
40
+ * typo-tolerant match that only kicks in when the query and word are close
41
+ * enough in length that a real typo is plausible (within 2 characters) —
42
+ * this catches "pests" -> "pets" (one transposed letter, same length).
43
+ * Known limitation: two short, unrelated, similar-length words (e.g.
44
+ * "pets" vs "prefs") can still score moderately via this path — there's no
45
+ * algorithmic way to distinguish "typo of the same word" from "coincidence"
46
+ * at that length. In practice this only surfaces as a low-ranked also-ran
47
+ * behind any genuine stem match, and callers (see channel-tools.ts's
48
+ * channel_find description) are told to disambiguate on close scores
49
+ * rather than blindly trust the top result.
50
+ */
51
+ function wordScore(query, word) {
52
+ const stem = stemScore(query, word);
53
+ if (Math.abs(query.length - word.length) > 2)
54
+ return stem;
55
+ return Math.max(stem, typoScore(query, word));
56
+ }
57
+ /**
58
+ * Scores one candidate channel name against a query, case-insensitively:
59
+ * the best of (a) whole-string typo tolerance, (b) whole-string stem match,
60
+ * and (c) the best per-word score against the candidate's underscore/
61
+ * hyphen-split words (catches "pets" -> "pet_food_memory" via "pet", and
62
+ * "pests" -> "pets_discussion" via a length-gated typo match on "pets").
63
+ */
64
+ export function scoreChannelMatch(query, candidate) {
65
+ const q = query.toLowerCase();
66
+ const c = candidate.toLowerCase();
67
+ const whole = Math.max(typoScore(q, c), stemScore(q, c));
68
+ const perWord = words(c).map((w) => wordScore(q, w));
69
+ return Math.max(whole, ...perWord, 0);
70
+ }
71
+ /**
72
+ * Ranks every candidate against the query, highest score first, keeping
73
+ * only matches above `minScore` (default 0.5).
74
+ */
75
+ export function findChannelMatches(query, candidates, minScore = 0.5) {
76
+ return candidates
77
+ .map((name) => ({ name, score: scoreChannelMatch(query, name) }))
78
+ .filter((m) => m.score >= minScore)
79
+ .sort((a, b) => b.score - a.score);
80
+ }
@@ -0,0 +1,12 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { Tenant } from './tenant.js';
3
+ /**
4
+ * Registers `join_channel` and `list_channels` on an MCP server built via
5
+ * buildMcpServer. Shared across every mcp-tenant-lib consumer (mcp-form,
6
+ * js-bridge-mcp, ...) so the tool behavior/wording stays identical rather
7
+ * than reimplemented per package.
8
+ *
9
+ * `tenant`/`setChannel`/`port` are exactly what registerFn (see mcp.ts)
10
+ * receives — pass them straight through from there.
11
+ */
12
+ export declare function registerChannelTools<TSchema, TValues>(mcp: McpServer, tenant: () => Tenant<TSchema, TValues>, port: number, setChannel: (id: string) => void, initialSchema: TSchema, initialValues: TValues): void;
@@ -0,0 +1,61 @@
1
+ import { z } from 'zod';
2
+ import { tenants, getOrCreateTenant, isValidChannelName } from './tenant.js';
3
+ import { findChannelMatches } from './channel-search.js';
4
+ /**
5
+ * Registers `join_channel` and `list_channels` on an MCP server built via
6
+ * buildMcpServer. Shared across every mcp-tenant-lib consumer (mcp-form,
7
+ * js-bridge-mcp, ...) so the tool behavior/wording stays identical rather
8
+ * than reimplemented per package.
9
+ *
10
+ * `tenant`/`setChannel`/`port` are exactly what registerFn (see mcp.ts)
11
+ * receives — pass them straight through from there.
12
+ */
13
+ export function registerChannelTools(mcp, tenant, port, setChannel, initialSchema, initialValues) {
14
+ mcp.tool('join_channel', 'Names (or rejoins) a channel: a persistent, agent-chosen identity for this session\'s live state, shared ' +
15
+ 'with any other session that joins the same name. DEFAULT TO CALLING THIS as one of your first actions, ' +
16
+ 'with a name derived from the topic at hand (e.g. a request about "pets" → join_channel("pets")) — not ' +
17
+ 'something reserved for when you happen to think of it. Until called, a session sits on the shared, ' +
18
+ 'anonymous "default" channel, visible to every other unnamed session on this server — skip only when the ' +
19
+ 'user says it\'s a one-off/throwaway, or nothing suggests a distinct topic worth naming. Reusing an ' +
20
+ '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
+ if (!isValidChannelName(channel)) {
23
+ return {
24
+ content: [{ type: 'text', text: `Error: "${channel}" is not a valid channel name — use only letters, digits, underscore, and hyphen.` }],
25
+ isError: true,
26
+ };
27
+ }
28
+ getOrCreateTenant(channel, initialSchema, initialValues);
29
+ setChannel(channel);
30
+ return {
31
+ content: [{ type: 'text', text: `Joined channel "${channel}" — http://localhost:${port}/t/${channel}` }],
32
+ };
33
+ });
34
+ 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 () => {
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) }] };
48
+ });
49
+ mcp.tool('channel_find', 'Fuzzy-searches existing channel names for ones matching a loose query — use this when a human refers to ' +
50
+ 'a channel by topic or partial name (e.g. "the pets channel") rather than its exact name, instead of ' +
51
+ 'guessing at join_channel or falling back to eyeballing the full list_channels output yourself. Matches on ' +
52
+ 'both whole-name similarity (catches typos) and per-word similarity against underscore/hyphen-split parts ' +
53
+ 'of each name (catches "pets" matching "pet_food_memory" via the shared word "pet"). Returns a ranked list ' +
54
+ 'of {name, score} (score 0..1, higher is better) — empty if nothing scores above the threshold. Read-only: ' +
55
+ 'does NOT join or create anything, even on an exact match. Given the result(s), call join_channel yourself ' +
56
+ 'on whichever one is actually right — if there\'s one clear best match, use it directly; if several score ' +
57
+ 'closely, ask the user to disambiguate rather than guessing.', { query: z.string().describe('Loose/partial channel name or topic to search for, e.g. "pets".') }, async ({ query }) => {
58
+ const matches = findChannelMatches(query, [...tenants.keys()]);
59
+ return { content: [{ type: 'text', text: JSON.stringify(matches, null, 2) }] };
60
+ });
61
+ }
@@ -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();
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep } from './tenant.js';
1
+ export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName } from './tenant.js';
2
2
  export { buildMcpServer, type RegisterToolsFn, type McpServerIdentity } from './mcp.js';
3
3
  export { createHttpServer, type CreateHttpServerOptions } from './http.js';
4
4
  export { attachWebSocketServer } from './ws.js';
5
5
  export { createManifestToolRegistry, type ManifestToolRegistry } from './manifest-tools.js';
6
+ export { registerChannelTools } from './channel-tools.js';
7
+ export { findChannelMatches, scoreChannelMatch, type ChannelMatch } from './channel-search.js';
6
8
  export * from './types.js';
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
- export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep } from './tenant.js';
1
+ export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName } from './tenant.js';
2
2
  export { buildMcpServer } from './mcp.js';
3
3
  export { createHttpServer } from './http.js';
4
4
  export { attachWebSocketServer } from './ws.js';
5
5
  export { createManifestToolRegistry } from './manifest-tools.js';
6
+ export { registerChannelTools } from './channel-tools.js';
7
+ export { findChannelMatches, scoreChannelMatch } from './channel-search.js';
6
8
  export * from './types.js';
@@ -25,7 +25,13 @@ function manifestEntryToZodShape(entry) {
25
25
  return shape;
26
26
  }
27
27
  const DESCRIBE_TOOLS_NAME = 'describe_tools';
28
- const DESCRIBE_TOOLS_DESCRIPTION = 'Returns manifest-level context for the tools this connected page registered: a ' +
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.';
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 ' +
31
37
  'this once after connecting, before calling any other tool from this page, so you have ' +
@@ -33,7 +39,11 @@ const DESCRIBE_TOOLS_DESCRIPTION = 'Returns manifest-level context for the tools
33
39
  'pages/tabs are connected to this session at once, tool names are prefixed per ' +
34
40
  'connection (e.g. "formalin__submit_form", "htmlpaint__clear_canvas") and this tool\'s ' +
35
41
  'response includes a `connections` array listing each connection\'s id, label, and ' +
36
- 'prefix — call it whenever you\'re unsure which prefix routes to which tab.';
42
+ 'prefix — call it whenever you\'re unsure which prefix routes to which tab. ' +
43
+ 'IMPORTANT — an empty or unexpected result here does NOT mean no page is bridged: this session may ' +
44
+ 'simply be on the wrong channel (see join_channel). If the user expects a specific bridged app/page by ' +
45
+ '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.';
37
47
  /**
38
48
  * Derives a stable, unique tool-name prefix per connection: sanitized from
39
49
  * `label` (falling back to "tab" when absent or empty after sanitizing),
@@ -107,6 +117,24 @@ export function createManifestToolRegistry(mcp, tenant) {
107
117
  });
108
118
  handles.set(DESCRIBE_TOOLS_NAME, handle);
109
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
+ }
110
138
  function sync() {
111
139
  const conns = [...tenant().connections.values()];
112
140
  const multi = conns.length >= 2;
@@ -118,7 +146,7 @@ export function createManifestToolRegistry(mcp, tenant) {
118
146
  if (multi) {
119
147
  for (const conn of conns) {
120
148
  for (const entry of conn.manifest) {
121
- if (entry.name === DESCRIBE_TOOLS_NAME)
149
+ if (entry.name === DESCRIBE_TOOLS_NAME || entry.name === IDENTIFY_CONNECTION_NAME)
122
150
  continue;
123
151
  registeredNow.set(`${slugFor.get(conn.id)}__${entry.name}`, { connectionId: conn.id, entry });
124
152
  }
@@ -127,14 +155,15 @@ export function createManifestToolRegistry(mcp, tenant) {
127
155
  else {
128
156
  const conn = conns[0];
129
157
  for (const entry of tenant().toolManifest) {
130
- if (entry.name === DESCRIBE_TOOLS_NAME)
158
+ if (entry.name === DESCRIBE_TOOLS_NAME || entry.name === IDENTIFY_CONNECTION_NAME)
131
159
  continue;
132
160
  registeredNow.set(entry.name, { connectionId: conn?.id, entry });
133
161
  }
134
162
  }
135
- // A page tool named "describe_tools" would collide with the fixed tool
136
- // below - the fixed one always wins so agents can rely on the name.
137
- 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()]);
138
167
  for (const [name, handle] of handles) {
139
168
  if (!currentNames.has(name)) {
140
169
  handle.remove();
@@ -143,6 +172,8 @@ export function createManifestToolRegistry(mcp, tenant) {
143
172
  }
144
173
  if (!handles.has(DESCRIBE_TOOLS_NAME))
145
174
  registerDescribeTools();
175
+ if (!handles.has(IDENTIFY_CONNECTION_NAME))
176
+ registerIdentifyConnection();
146
177
  for (const [registeredName, { connectionId, entry }] of registeredNow) {
147
178
  if (handles.has(registeredName))
148
179
  continue;
package/dist/mcp.d.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { Tenant } from './tenant.js';
3
- export type RegisterToolsFn<TSchema = any, TValues = any> = (mcp: McpServer, tenant: () => Tenant<TSchema, TValues>, port: number) => void;
3
+ export type RegisterToolsFn<TSchema = any, TValues = any> = (mcp: McpServer, tenant: () => Tenant<TSchema, TValues>, port: number, setChannel: (id: string) => void) => void;
4
4
  export interface McpServerIdentity {
5
5
  name: string;
6
6
  version: string;
7
7
  }
8
+ /**
9
+ * `tenantId` is only the session's bootstrap identity — a private id minted
10
+ * before any tool call has happened, so before an agent could have chosen a
11
+ * channel name (see channel-tools.ts). It is NOT fixed for the session's
12
+ * lifetime: `setChannel` (passed into `registerFn`, typically wired to a
13
+ * `join_channel` tool) reassigns which tenant `tenant()` resolves to from
14
+ * that point on, so a session can retarget itself onto an agent-named,
15
+ * cross-session-shared channel after connecting.
16
+ */
8
17
  export declare function buildMcpServer<TSchema, TValues>(identity: McpServerIdentity, tenantId: string, getTenant: (id: string) => Tenant<TSchema, TValues>, port: number, registerFn: RegisterToolsFn<TSchema, TValues>): McpServer;
package/dist/mcp.js CHANGED
@@ -1,7 +1,18 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ /**
3
+ * `tenantId` is only the session's bootstrap identity — a private id minted
4
+ * before any tool call has happened, so before an agent could have chosen a
5
+ * channel name (see channel-tools.ts). It is NOT fixed for the session's
6
+ * lifetime: `setChannel` (passed into `registerFn`, typically wired to a
7
+ * `join_channel` tool) reassigns which tenant `tenant()` resolves to from
8
+ * that point on, so a session can retarget itself onto an agent-named,
9
+ * cross-session-shared channel after connecting.
10
+ */
2
11
  export function buildMcpServer(identity, tenantId, getTenant, port, registerFn) {
3
12
  const mcp = new McpServer(identity);
4
- const tenant = () => getTenant(tenantId);
5
- registerFn(mcp, tenant, port);
13
+ let currentTenantId = tenantId;
14
+ const tenant = () => getTenant(currentTenantId);
15
+ const setChannel = (id) => { currentTenantId = id; };
16
+ registerFn(mcp, tenant, port, setChannel);
6
17
  return mcp;
7
18
  }
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;
@@ -135,8 +143,15 @@ export declare class Tenant<TSchema, TValues> {
135
143
  broadcastUpdate(field: string, value: unknown): void;
136
144
  dispose(): void;
137
145
  }
146
+ /**
147
+ * URL-safe slug rule for agent-chosen channel names (see channel-tools.ts):
148
+ * letters, digits, underscore, hyphen only. Channel names become part of the
149
+ * form URL path (`/t/<id>`), so anything requiring percent-encoding is
150
+ * rejected up front rather than silently mangled.
151
+ */
152
+ declare function isValidChannelName(id: string): boolean;
138
153
  declare const tenants: Map<string, Tenant<any, any>>;
139
154
  declare function getOrCreateTenant<TSchema, TValues>(id: string, initialSchema: TSchema, initialValues: TValues): Tenant<TSchema, TValues>;
140
155
  declare function disposeTenant(id: string): void;
141
156
  declare function startIdleSweep(onSweep: (id: string) => void): NodeJS.Timeout;
142
- export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep };
157
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName };
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)
@@ -325,6 +339,15 @@ export class Tenant {
325
339
  this.connections.clear();
326
340
  }
327
341
  }
342
+ /**
343
+ * URL-safe slug rule for agent-chosen channel names (see channel-tools.ts):
344
+ * letters, digits, underscore, hyphen only. Channel names become part of the
345
+ * form URL path (`/t/<id>`), so anything requiring percent-encoding is
346
+ * rejected up front rather than silently mangled.
347
+ */
348
+ function isValidChannelName(id) {
349
+ return /^[a-zA-Z0-9_-]+$/.test(id);
350
+ }
328
351
  const tenants = new Map();
329
352
  function getOrCreateTenant(id, initialSchema, initialValues) {
330
353
  let tenant = tenants.get(id);
@@ -345,7 +368,7 @@ function envMs(name, defaultMs) {
345
368
  const n = Number(raw);
346
369
  return Number.isFinite(n) && n > 0 ? n : defaultMs;
347
370
  }
348
- const TENANT_IDLE_TIMEOUT_MS = envMs('TENANT_IDLE_TIMEOUT_MS', 30 * 60 * 1000);
371
+ const TENANT_IDLE_TIMEOUT_MS = envMs('TENANT_IDLE_TIMEOUT_MS', 2 * 60 * 60 * 1000);
349
372
  const TENANT_SWEEP_INTERVAL_MS = envMs('TENANT_SWEEP_INTERVAL_MS', 5 * 60 * 1000);
350
373
  function startIdleSweep(onSweep) {
351
374
  const sweepInterval = setInterval(() => {
@@ -362,4 +385,4 @@ function startIdleSweep(onSweep) {
362
385
  sweepInterval.unref();
363
386
  return sweepInterval;
364
387
  }
365
- export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep };
388
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName };
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.1.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "Generic tenant/session bookkeeping + MCP wiring, reusable across projects.",
6
6
  "repository": {
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@modelcontextprotocol/sdk": "^1.12.0",
38
+ "fastest-levenshtein": "^1.0.16",
38
39
  "ws": "^8.18.0",
39
40
  "zod": "^3.23.8"
40
41
  },