mcp-tenant-lib 0.3.5 → 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.
@@ -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
  }
@@ -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';
@@ -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) }] };
@@ -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.5",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "description": "Generic tenant/session bookkeeping + MCP wiring, reusable across projects.",
6
6
  "repository": {