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.
- package/dist/channel-tools.js +26 -6
- package/dist/dashboard.d.ts +33 -0
- package/dist/dashboard.js +74 -0
- package/dist/http.d.ts +16 -1
- package/dist/http.js +32 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/manifest-tools.d.ts +27 -0
- package/dist/manifest-tools.js +52 -25
- package/dist/persistence.d.ts +19 -0
- package/dist/persistence.js +98 -0
- package/dist/tenant.d.ts +28 -1
- package/dist/tenant.js +39 -1
- package/dist/ws.js +16 -7
- package/package.json +1 -1
package/dist/channel-tools.js
CHANGED
|
@@ -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.
|
|
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
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
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 = {
|
|
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
|
-
|
|
147
|
-
|
|
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';
|
package/dist/manifest-tools.d.ts
CHANGED
|
@@ -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
|
package/dist/manifest-tools.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
34
|
+
let requestedTenantId = wsUrl.searchParams.get('tenant');
|
|
35
35
|
if (requestedTenantId && !isValidChannelName(requestedTenantId)) {
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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);
|