mcp-tenant-lib 0.3.6 → 0.3.7

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,33 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ export interface DashboardConnection {
3
+ id: string;
4
+ label: string | null;
5
+ toolCount: number;
6
+ summary: string | null;
7
+ }
8
+ export interface DashboardChannel {
9
+ channel: string;
10
+ lastActivityAt: number;
11
+ connections: DashboardConnection[];
12
+ }
13
+ /**
14
+ * Flat snapshot of every live tenant ("channel" in agent-facing language)
15
+ * and its connections — the same data list_channels/describe_channel expose
16
+ * to MCP tools, reshaped for a human-facing dashboard. Rebuilt fresh on
17
+ * every call rather than cached: cheap (iterates in-memory maps only), and
18
+ * avoids a second source of truth to keep in sync with `tenants`.
19
+ */
20
+ export declare function buildDashboardSnapshot(): DashboardChannel[];
21
+ /**
22
+ * Handles the three dashboard HTTP routes against a raw node http.Server,
23
+ * the same low-level style createHttpServer (http.ts) already uses (no
24
+ * Express dependency in this package). Returns true if the request was
25
+ * handled (caller should stop routing further), false if the path/method
26
+ * didn't match anything here.
27
+ *
28
+ * Mount this ahead of static file serving in the consuming package's own
29
+ * request handler, e.g.:
30
+ *
31
+ * if (handleDashboardRoutes(req, res)) return;
32
+ */
33
+ export declare function handleDashboardRoutes(req: IncomingMessage, res: ServerResponse, port: number): boolean;
@@ -0,0 +1,74 @@
1
+ import { tenants, dashboardEvents } from './tenant.js';
2
+ /**
3
+ * Flat snapshot of every live tenant ("channel" in agent-facing language)
4
+ * and its connections — the same data list_channels/describe_channel expose
5
+ * to MCP tools, reshaped for a human-facing dashboard. Rebuilt fresh on
6
+ * every call rather than cached: cheap (iterates in-memory maps only), and
7
+ * avoids a second source of truth to keep in sync with `tenants`.
8
+ */
9
+ export function buildDashboardSnapshot() {
10
+ return [...tenants.entries()]
11
+ .map(([channel, t]) => ({
12
+ channel,
13
+ lastActivityAt: t.lastActivityAt,
14
+ connections: [...t.connections.values()].map((c) => ({
15
+ id: c.id,
16
+ label: c.label ?? null,
17
+ toolCount: c.manifest.length,
18
+ summary: c.summary ?? null,
19
+ })),
20
+ }))
21
+ .sort((a, b) => b.lastActivityAt - a.lastActivityAt);
22
+ }
23
+ /**
24
+ * Handles the three dashboard HTTP routes against a raw node http.Server,
25
+ * the same low-level style createHttpServer (http.ts) already uses (no
26
+ * Express dependency in this package). Returns true if the request was
27
+ * handled (caller should stop routing further), false if the path/method
28
+ * didn't match anything here.
29
+ *
30
+ * Mount this ahead of static file serving in the consuming package's own
31
+ * request handler, e.g.:
32
+ *
33
+ * if (handleDashboardRoutes(req, res)) return;
34
+ */
35
+ export function handleDashboardRoutes(req, res, port) {
36
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
37
+ if (url.pathname === '/api/dashboard' && req.method === 'GET') {
38
+ res.writeHead(200, { 'Content-Type': 'application/json' });
39
+ res.end(JSON.stringify(buildDashboardSnapshot()));
40
+ return true;
41
+ }
42
+ // Server-sent-events stream: pushes a fresh full snapshot immediately on
43
+ // connect, then again every time dashboardEvents fires (a connection
44
+ // opened/closed, a manifest changed, a channel was created/disposed) — see
45
+ // tenant.ts's notifyDashboard call sites. No diffing: the snapshot is
46
+ // small (one row per channel/connection) and a full replace is simpler
47
+ // and less bug-prone client-side than patching.
48
+ if (url.pathname === '/api/dashboard/stream' && req.method === 'GET') {
49
+ res.writeHead(200, {
50
+ 'Content-Type': 'text/event-stream',
51
+ 'Cache-Control': 'no-cache',
52
+ Connection: 'keep-alive',
53
+ });
54
+ const send = () => res.write(`data: ${JSON.stringify(buildDashboardSnapshot())}\n\n`);
55
+ send();
56
+ dashboardEvents.on('change', send);
57
+ req.on('close', () => dashboardEvents.off('change', send));
58
+ return true;
59
+ }
60
+ // Triggers identifyConnection on one connection — the dashboard's "which
61
+ // tab is this" button, same underlying mechanism as the identify_connection
62
+ // MCP tool (manifest-tools.ts), just reachable from a human clicking
63
+ // instead of an agent calling a tool.
64
+ const identifyMatch = url.pathname.match(/^\/api\/dashboard\/channels\/([^/]+)\/connections\/([^/]+)\/identify$/);
65
+ if (identifyMatch && req.method === 'POST') {
66
+ const [, channel, connectionId] = identifyMatch;
67
+ const t = tenants.get(decodeURIComponent(channel));
68
+ const ok = t?.identifyConnection(decodeURIComponent(connectionId)) ?? false;
69
+ res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify({ ok }));
71
+ return true;
72
+ }
73
+ return false;
74
+ }
package/dist/http.d.ts CHANGED
@@ -28,5 +28,20 @@ export interface CreateHttpServerOptions<TSchema, TValues> {
28
28
  * to the previous one.
29
29
  */
30
30
  defaultTenantMode?: 'per-session' | 'shared';
31
+ /**
32
+ * Extra static asset roots served ahead of `staticDir`, keyed by URL path
33
+ * prefix. Two shapes:
34
+ * - A directory-style prefix (e.g. `{ '/dashboard': '.../dist/dashboard' }`)
35
+ * resolves requests under it relative to that directory, falling back
36
+ * to `index.html` for the bare prefix or a `/`-suffixed request — same
37
+ * convention as `staticDir`. Lets a consumer ship a second, independent
38
+ * static app (e.g. a monitoring dashboard) without colliding with
39
+ * `staticDir`'s own `index.html`.
40
+ * - A single-file prefix with an extension (e.g. `{ '/main.js': '.../dist/client' }`)
41
+ * serves exactly that one file (`dir/main.js`) for a request matching
42
+ * the prefix exactly — for a fixed-URL asset other pages already
43
+ * reference by that path regardless of what's mounted at `/`.
44
+ */
45
+ extraStaticMounts?: Record<string, string>;
31
46
  }
32
- export declare function createHttpServer<TSchema, TValues>({ port, staticDir, initialSchema, initialValues, identity, registerFn, defaultTenantMode }: CreateHttpServerOptions<TSchema, TValues>): http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
47
+ export declare function createHttpServer<TSchema, TValues>({ port, staticDir, initialSchema, initialValues, identity, registerFn, defaultTenantMode, extraStaticMounts }: CreateHttpServerOptions<TSchema, TValues>): http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
package/dist/http.js CHANGED
@@ -6,11 +6,18 @@ import { randomUUID } from 'node:crypto';
6
6
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
7
  import { getOrCreateTenant } from './tenant.js';
8
8
  import { buildMcpServer } from './mcp.js';
9
+ import { handleDashboardRoutes } from './dashboard.js';
9
10
  const UPLOAD_DIR = path.join(os.tmpdir(), 'mcp-form-uploads');
10
11
  fs.mkdirSync(UPLOAD_DIR, { recursive: true });
11
- const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json' };
12
+ const mime = {
13
+ '.html': 'text/html',
14
+ '.js': 'text/javascript',
15
+ '.json': 'application/json',
16
+ '.css': 'text/css',
17
+ '.svg': 'image/svg+xml',
18
+ };
12
19
  const sessions = new Map();
13
- export function createHttpServer({ port, staticDir, initialSchema, initialValues, identity, registerFn, defaultTenantMode = 'per-session' }) {
20
+ export function createHttpServer({ port, staticDir, initialSchema, initialValues, identity, registerFn, defaultTenantMode = 'per-session', extraStaticMounts = {} }) {
14
21
  const getTenant = (id) => {
15
22
  const t = getOrCreateTenant(id, initialSchema, initialValues);
16
23
  t.touch();
@@ -96,6 +103,8 @@ export function createHttpServer({ port, staticDir, initialSchema, initialValues
96
103
  res.end('Unknown session');
97
104
  return;
98
105
  }
106
+ if (url.pathname.startsWith('/api/dashboard') && handleDashboardRoutes(req, res, port))
107
+ return;
99
108
  if (url.pathname === '/upload' && req.method === 'POST') {
100
109
  const contentType = req.headers['content-type'] ?? '';
101
110
  const boundaryMatch = contentType.match(/boundary=(.+)$/);
@@ -143,8 +152,27 @@ export function createHttpServer({ port, staticDir, initialSchema, initialValues
143
152
  if (pathname.startsWith('/t/')) {
144
153
  pathname = '/';
145
154
  }
146
- let filePath = pathname === '/' ? '/index.html' : pathname;
147
- filePath = path.join(staticDir, filePath);
155
+ const mountEntry = Object.entries(extraStaticMounts).find(([prefix]) => pathname === prefix || pathname.startsWith(`${prefix}/`));
156
+ let filePath;
157
+ if (mountEntry) {
158
+ const [prefix, dir] = mountEntry;
159
+ if (path.extname(prefix)) {
160
+ // A prefix with a file extension (e.g. "/main.js") is a single-file
161
+ // mount — `dir` is that file's parent directory, and any request
162
+ // exactly matching `prefix` resolves straight to it, no index.html
163
+ // fallback. Lets a consumer serve one fixed-URL asset (e.g. an
164
+ // embed script referenced by other pages as "<server>/main.js")
165
+ // from a build directory that also happens to hold other files.
166
+ filePath = path.join(dir, path.basename(prefix));
167
+ }
168
+ else {
169
+ const rest = pathname.slice(prefix.length);
170
+ filePath = path.join(dir, rest === '' || rest === '/' ? '/index.html' : rest);
171
+ }
172
+ }
173
+ else {
174
+ filePath = path.join(staticDir, pathname === '/' ? '/index.html' : pathname);
175
+ }
148
176
  fs.readFile(filePath, (err, data) => {
149
177
  if (err) {
150
178
  res.writeHead(404);
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
- export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName } from './tenant.js';
1
+ export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName, sanitizeChannelName, dashboardEvents } 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
6
  export { registerChannelTools } from './channel-tools.js';
7
+ export { handleDashboardRoutes, buildDashboardSnapshot, type DashboardChannel, type DashboardConnection } from './dashboard.js';
7
8
  export { findChannelMatches, scoreChannelMatch, type ChannelMatch } from './channel-search.js';
9
+ export { enablePersistence } from './persistence.js';
8
10
  export * from './types.js';
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
- export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName } from './tenant.js';
1
+ export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName, sanitizeChannelName, dashboardEvents } 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
6
  export { registerChannelTools } from './channel-tools.js';
7
+ export { handleDashboardRoutes, buildDashboardSnapshot } from './dashboard.js';
7
8
  export { findChannelMatches, scoreChannelMatch } from './channel-search.js';
9
+ export { enablePersistence } from './persistence.js';
8
10
  export * from './types.js';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Loads persisted tenant state from `filePath` (if present) and pre-seeds
3
+ * the shared `tenants` map with it *before* any getOrCreateTenant('default')
4
+ * call runs at boot — so a server restart comes back up with the last known
5
+ * form schema/values already in place instead of blank defaults, without
6
+ * requiring any browser tab to still be open to push a resync (see
7
+ * Tenant.restoreState in tenant.ts, which only helps if a tab survived the
8
+ * restart). Returns the set of tenant ids it seeded, purely for logging.
9
+ *
10
+ * Then wires a debounced save-on-change: any tenant currently in memory
11
+ * (present or created afterward) that mutates its store gets its next
12
+ * snapshot written back within WRITE_DEBOUNCE_MS. Polling `tenants`
13
+ * directly (rather than requiring every getOrCreateTenant call site to opt
14
+ * in) means callers of enablePersistence don't need to change how they
15
+ * create tenants elsewhere in the codebase.
16
+ */
17
+ export declare function enablePersistence<TSchema, TValues>(filePath: string): {
18
+ seededIds: string[];
19
+ };
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Tenant, tenants } from './tenant.js';
4
+ /**
5
+ * Debounce delay between a tenant edit and the write hitting disk. Keeps
6
+ * rapid-fire field edits (typing in a text input broadcasts on every
7
+ * keystroke) from turning into one fsync per keystroke, while staying well
8
+ * under any timeframe a user would notice as "my edit wasn't saved".
9
+ */
10
+ const WRITE_DEBOUNCE_MS = 500;
11
+ /**
12
+ * Loads persisted tenant state from `filePath` (if present) and pre-seeds
13
+ * the shared `tenants` map with it *before* any getOrCreateTenant('default')
14
+ * call runs at boot — so a server restart comes back up with the last known
15
+ * form schema/values already in place instead of blank defaults, without
16
+ * requiring any browser tab to still be open to push a resync (see
17
+ * Tenant.restoreState in tenant.ts, which only helps if a tab survived the
18
+ * restart). Returns the set of tenant ids it seeded, purely for logging.
19
+ *
20
+ * Then wires a debounced save-on-change: any tenant currently in memory
21
+ * (present or created afterward) that mutates its store gets its next
22
+ * snapshot written back within WRITE_DEBOUNCE_MS. Polling `tenants`
23
+ * directly (rather than requiring every getOrCreateTenant call site to opt
24
+ * in) means callers of enablePersistence don't need to change how they
25
+ * create tenants elsewhere in the codebase.
26
+ */
27
+ export function enablePersistence(filePath) {
28
+ const seededIds = [];
29
+ let persisted = {};
30
+ try {
31
+ const raw = fs.readFileSync(filePath, 'utf-8');
32
+ persisted = JSON.parse(raw);
33
+ }
34
+ catch (err) {
35
+ if (err.code !== 'ENOENT') {
36
+ console.error(`[persistence] failed to read ${filePath}: ${err.message}`);
37
+ }
38
+ }
39
+ for (const [id, state] of Object.entries(persisted)) {
40
+ if (tenants.has(id))
41
+ continue;
42
+ const tenant = new Tenant(id, state.schema, state.values);
43
+ tenant.submitted = state.submitted;
44
+ tenant.lastStateChangeAt = state.lastStateChangeAt;
45
+ tenant.lastActivityAt = state.lastActivityAt;
46
+ tenants.set(id, tenant);
47
+ seededIds.push(id);
48
+ }
49
+ const writeToDisk = () => {
50
+ const out = {};
51
+ for (const [id, tenant] of tenants) {
52
+ out[id] = {
53
+ schema: tenant.schema,
54
+ values: tenant.store.snapshot(),
55
+ submitted: tenant.submitted,
56
+ lastStateChangeAt: tenant.lastStateChangeAt,
57
+ lastActivityAt: tenant.lastActivityAt,
58
+ };
59
+ }
60
+ try {
61
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
62
+ fs.writeFileSync(filePath, JSON.stringify(out));
63
+ }
64
+ catch (err) {
65
+ console.error(`[persistence] failed to write ${filePath}: ${err.message}`);
66
+ }
67
+ };
68
+ // Polls `lastStateChangeAt` (bumped on every real store edit or applied
69
+ // resync — see Tenant#attachStore/restoreState) rather than subscribing
70
+ // to each tenant's `store.onChange`, since define_form/resync/reconnect
71
+ // all replace `tenant.store` with a brand-new Store (Tenant#attachStore),
72
+ // which would silently drop a direct subscription. Polling at the same
73
+ // cadence as the write debounce means a change is never more than one
74
+ // interval late to be noticed, and comparing against a per-tenant
75
+ // last-seen timestamp keeps this a no-op when nothing changed.
76
+ const seen = new Map();
77
+ for (const [id, tenant] of tenants)
78
+ seen.set(id, tenant.lastStateChangeAt);
79
+ const pollInterval = setInterval(() => {
80
+ let dirty = false;
81
+ for (const [id, tenant] of tenants) {
82
+ if (seen.get(id) !== tenant.lastStateChangeAt) {
83
+ seen.set(id, tenant.lastStateChangeAt);
84
+ dirty = true;
85
+ }
86
+ }
87
+ for (const id of seen.keys()) {
88
+ if (!tenants.has(id)) {
89
+ seen.delete(id);
90
+ dirty = true;
91
+ } // tenant disposed (idle sweep)
92
+ }
93
+ if (dirty)
94
+ writeToDisk();
95
+ }, WRITE_DEBOUNCE_MS);
96
+ pollInterval.unref();
97
+ return { seededIds };
98
+ }
package/dist/tenant.d.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import type { WebSocket } from 'ws';
3
3
  import type { ToolManifestEntry } from './types.js';
4
+ /**
5
+ * Fires whenever the *shape* of the tenants map changes in a way a
6
+ * dashboard/monitoring view would care about: a tenant created/disposed, a
7
+ * connection opening/closing, or a connection's manifest/label changing.
8
+ * Deliberately does NOT fire on ordinary state (Store) changes or waiting/
9
+ * submitted flips — those are per-tenant form data, not "who's connected."
10
+ * One process-wide emitter (not per-tenant) so a single dashboard SSE
11
+ * stream can subscribe once for every channel rather than one listener per
12
+ * tenant. See dashboard.ts for the consumer.
13
+ */
14
+ export declare const dashboardEvents: EventEmitter<[never]>;
4
15
  /**
5
16
  * One WS connection's own tool manifest/summary/label, addressable
6
17
  * independently of other connections sharing the same tenant (e.g. two
@@ -181,8 +192,24 @@ export declare class Tenant<TSchema, TValues> {
181
192
  * rejected up front rather than silently mangled.
182
193
  */
183
194
  declare function isValidChannelName(id: string): boolean;
195
+ /**
196
+ * Best-effort fixup for a browser-supplied channel name that fails
197
+ * isValidChannelName — collapses any run of disallowed characters (spaces,
198
+ * punctuation, etc.) into a single underscore and trims leading/trailing
199
+ * underscores, e.g. "bulletino 222aaa" -> "bulletino_222aaa". Used only on
200
+ * the WS connect path (ws.ts): a human renaming a bridged tab via a plain
201
+ * `prompt()` has no reason to know or care about the exact slug rule, so
202
+ * silently coercing their input into something valid is friendlier than a
203
+ * hard 4404 reject with no recovery. Deliberately NOT applied to
204
+ * join_channel (channel-tools.ts) — that's agent-driven, the tool
205
+ * description already states the rule up front, and an agent silently
206
+ * landing on a DIFFERENT name than it asked for is more likely to cause
207
+ * confusion (e.g. mismatched describe_channel lookups) than a clear
208
+ * rejection it can self-correct from.
209
+ */
210
+ declare function sanitizeChannelName(id: string): string;
184
211
  declare const tenants: Map<string, Tenant<any, any>>;
185
212
  declare function getOrCreateTenant<TSchema, TValues>(id: string, initialSchema: TSchema, initialValues: TValues): Tenant<TSchema, TValues>;
186
213
  declare function disposeTenant(id: string): void;
187
214
  declare function startIdleSweep(onSweep: (id: string) => void): NodeJS.Timeout;
188
- export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName };
215
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName, sanitizeChannelName };
package/dist/tenant.js CHANGED
@@ -1,5 +1,20 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { randomUUID } from 'node:crypto';
3
+ /**
4
+ * Fires whenever the *shape* of the tenants map changes in a way a
5
+ * dashboard/monitoring view would care about: a tenant created/disposed, a
6
+ * connection opening/closing, or a connection's manifest/label changing.
7
+ * Deliberately does NOT fire on ordinary state (Store) changes or waiting/
8
+ * submitted flips — those are per-tenant form data, not "who's connected."
9
+ * One process-wide emitter (not per-tenant) so a single dashboard SSE
10
+ * stream can subscribe once for every channel rather than one listener per
11
+ * tenant. See dashboard.ts for the consumer.
12
+ */
13
+ export const dashboardEvents = new EventEmitter();
14
+ dashboardEvents.setMaxListeners(0);
15
+ function notifyDashboard() {
16
+ dashboardEvents.emit('change');
17
+ }
3
18
  /**
4
19
  * How long Tenant.call waits for some connection to reappear on a tenant
5
20
  * before giving up, when the connection it was targeting has already
@@ -144,6 +159,7 @@ export class Tenant {
144
159
  registerConnection(id, socket) {
145
160
  this.connections.set(id, { id, socket, manifest: [], summary: undefined, label: undefined });
146
161
  this.wsClients.add(socket);
162
+ notifyDashboard();
147
163
  }
148
164
  updateConnectionManifest(id, manifest, summary, label) {
149
165
  const conn = this.connections.get(id);
@@ -174,6 +190,7 @@ export class Tenant {
174
190
  this.wsClients.delete(conn.socket);
175
191
  this.connections.delete(id);
176
192
  this.syncManifestToolRegistries();
193
+ notifyDashboard();
177
194
  }
178
195
  addManifestToolRegistry(registry) {
179
196
  this.#manifestToolRegistries.add(registry);
@@ -185,6 +202,7 @@ export class Tenant {
185
202
  syncManifestToolRegistries() {
186
203
  for (const registry of this.#manifestToolRegistries)
187
204
  registry.sync();
205
+ notifyDashboard();
188
206
  }
189
207
  /**
190
208
  * `connectionId` targets the call at one specific connection's socket —
@@ -395,18 +413,38 @@ export class Tenant {
395
413
  function isValidChannelName(id) {
396
414
  return /^[a-zA-Z0-9_-]+$/.test(id);
397
415
  }
416
+ /**
417
+ * Best-effort fixup for a browser-supplied channel name that fails
418
+ * isValidChannelName — collapses any run of disallowed characters (spaces,
419
+ * punctuation, etc.) into a single underscore and trims leading/trailing
420
+ * underscores, e.g. "bulletino 222aaa" -> "bulletino_222aaa". Used only on
421
+ * the WS connect path (ws.ts): a human renaming a bridged tab via a plain
422
+ * `prompt()` has no reason to know or care about the exact slug rule, so
423
+ * silently coercing their input into something valid is friendlier than a
424
+ * hard 4404 reject with no recovery. Deliberately NOT applied to
425
+ * join_channel (channel-tools.ts) — that's agent-driven, the tool
426
+ * description already states the rule up front, and an agent silently
427
+ * landing on a DIFFERENT name than it asked for is more likely to cause
428
+ * confusion (e.g. mismatched describe_channel lookups) than a clear
429
+ * rejection it can self-correct from.
430
+ */
431
+ function sanitizeChannelName(id) {
432
+ return id.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '');
433
+ }
398
434
  const tenants = new Map();
399
435
  function getOrCreateTenant(id, initialSchema, initialValues) {
400
436
  let tenant = tenants.get(id);
401
437
  if (!tenant) {
402
438
  tenant = new Tenant(id, initialSchema, initialValues);
403
439
  tenants.set(id, tenant);
440
+ notifyDashboard();
404
441
  }
405
442
  return tenant;
406
443
  }
407
444
  function disposeTenant(id) {
408
445
  tenants.get(id)?.dispose();
409
446
  tenants.delete(id);
447
+ notifyDashboard();
410
448
  }
411
449
  function envMs(name, defaultMs) {
412
450
  const raw = process.env[name];
@@ -432,4 +470,4 @@ function startIdleSweep(onSweep) {
432
470
  sweepInterval.unref();
433
471
  return sweepInterval;
434
472
  }
435
- export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName };
473
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName, sanitizeChannelName };
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, isValidChannelName } from './tenant.js';
3
+ import { getOrCreateTenant, tenants, isValidChannelName, sanitizeChannelName } 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
@@ -31,13 +31,22 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
31
31
  wss.on('close', () => clearInterval(heartbeat));
32
32
  wss.on('connection', (ws, req) => {
33
33
  const wsUrl = new URL(req.url ?? '/', `http://localhost:${port}`);
34
- const requestedTenantId = wsUrl.searchParams.get('tenant');
34
+ let requestedTenantId = wsUrl.searchParams.get('tenant');
35
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');
40
- return;
36
+ // A browser-supplied name (e.g. a human renaming a bridged tab via a
37
+ // plain prompt()) has no reason to know the slug rule — coerce it
38
+ // into something valid rather than rejecting outright, same as
39
+ // join_channel would reject a raw name but this WS path favors
40
+ // recovering the connection. Only a genuinely empty result (every
41
+ // character was disallowed) still gets rejected below.
42
+ const sanitized = sanitizeChannelName(requestedTenantId);
43
+ if (!sanitized) {
44
+ console.error(`[ws] rejected connection: invalid tenant id "${requestedTenantId}" (nothing left after sanitizing)`);
45
+ ws.close(4404, 'Invalid tenant id');
46
+ return;
47
+ }
48
+ console.error(`[ws] sanitized invalid tenant id "${requestedTenantId}" -> "${sanitized}"`);
49
+ requestedTenantId = sanitized;
41
50
  }
42
51
  const tenantId = requestedTenantId || 'default';
43
52
  const recreated = !!requestedTenantId && !tenants.has(requestedTenantId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-tenant-lib",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "description": "Generic tenant/session bookkeeping + MCP wiring, reusable across projects.",
6
6
  "repository": {