mcp-tenant-lib 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel-tools.js +11 -3
- package/dist/client-bridge.d.ts +2 -0
- package/dist/client-bridge.js +27 -3
- package/dist/manifest-tools.js +32 -5
- package/dist/tenant.d.ts +8 -0
- package/dist/tenant.js +14 -0
- package/dist/types.d.ts +3 -0
- package/dist/ws.js +16 -7
- package/package.json +1 -1
package/dist/channel-tools.js
CHANGED
|
@@ -34,9 +34,17 @@ export function registerChannelTools(mcp, tenant, port, setChannel, initialSchem
|
|
|
34
34
|
mcp.tool('list_channels', 'Lists every channel currently live on this server, including "default" (the shared, anonymous channel ' +
|
|
35
35
|
'sessions land on before calling join_channel). Use this to discover an existing named channel before ' +
|
|
36
36
|
'calling join_channel on it, e.g. when a human refers to "the pets form" without giving the exact channel ' +
|
|
37
|
-
'name.
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
'name. Each entry includes a `connections` array — one item per live browser tab/page bridged into that ' +
|
|
38
|
+
'channel, with its display `label` and `toolCount` — so you can tell which channels actually have ' +
|
|
39
|
+
'something connected without joining each one to check.', {}, async () => {
|
|
40
|
+
const channels = [...tenants.entries()].map(([channel, t]) => ({
|
|
41
|
+
channel,
|
|
42
|
+
connections: [...t.connections.values()].map((c) => ({
|
|
43
|
+
label: c.label ?? null,
|
|
44
|
+
toolCount: c.manifest.length,
|
|
45
|
+
})),
|
|
46
|
+
}));
|
|
47
|
+
return { content: [{ type: 'text', text: JSON.stringify(channels, null, 2) }] };
|
|
40
48
|
});
|
|
41
49
|
mcp.tool('channel_find', 'Fuzzy-searches existing channel names for ones matching a loose query — use this when a human refers to ' +
|
|
42
50
|
'a channel by topic or partial name (e.g. "the pets channel") rather than its exact name, instead of ' +
|
package/dist/client-bridge.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface StateSocketHandlers<TSchema, TValues> {
|
|
|
36
36
|
onCall?(id: string, name: string, args: unknown): void;
|
|
37
37
|
onConnect?(): void;
|
|
38
38
|
onDisconnect?(): void;
|
|
39
|
+
/** Server-pushed "identify yourself" signal (see identify_connection tool). Defaults to a window.alert(). */
|
|
40
|
+
onIdentify?(label: string | undefined): void;
|
|
39
41
|
}
|
|
40
42
|
export interface StateSocketOptions {
|
|
41
43
|
/**
|
package/dist/client-bridge.js
CHANGED
|
@@ -30,10 +30,22 @@ export function createClientBridge(actions) {
|
|
|
30
30
|
},
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
|
+
/** Steady-state delay between reconnect attempts once the connection drops. */
|
|
34
|
+
const RECONNECT_INTERVAL_MS = 10_000;
|
|
35
|
+
/**
|
|
36
|
+
* How long to keep retrying at RECONNECT_INTERVAL_MS before giving up
|
|
37
|
+
* entirely. A dropped connection is usually the server restarting (or a
|
|
38
|
+
* laptop sleeping) rather than something permanent, and unknown named
|
|
39
|
+
* tenants are now recreated on demand server-side (see ws.ts) rather than
|
|
40
|
+
* rejected — so it's worth retrying for a long while rather than a few
|
|
41
|
+
* seconds, without retrying literally forever if the server is gone for good.
|
|
42
|
+
*/
|
|
43
|
+
const RECONNECT_GIVE_UP_MS = 60 * 60 * 1000;
|
|
33
44
|
export function connectStateSocket(handlers, options = {}) {
|
|
34
45
|
let ws;
|
|
35
46
|
let closedByCaller = false;
|
|
36
47
|
let reconnectAttempts = 0;
|
|
48
|
+
let firstDisconnectAt;
|
|
37
49
|
const connect = () => {
|
|
38
50
|
const tenantId = options.tenant ?? (location.pathname.startsWith('/t/')
|
|
39
51
|
? location.pathname.slice('/t/'.length).split('/')[0]
|
|
@@ -48,6 +60,7 @@ export function connectStateSocket(handlers, options = {}) {
|
|
|
48
60
|
ws.onopen = () => {
|
|
49
61
|
console.log(`[mcp-ws] connected${reconnectAttempts > 0 ? ` after ${reconnectAttempts} reconnect attempt(s)` : ''}`);
|
|
50
62
|
reconnectAttempts = 0;
|
|
63
|
+
firstDisconnectAt = undefined;
|
|
51
64
|
handlers.onConnect?.();
|
|
52
65
|
};
|
|
53
66
|
ws.onclose = (event) => {
|
|
@@ -56,12 +69,17 @@ export function connectStateSocket(handlers, options = {}) {
|
|
|
56
69
|
if (closedByCaller)
|
|
57
70
|
return;
|
|
58
71
|
if (event.code === 4404) {
|
|
59
|
-
console.log('[mcp-ws] tenant
|
|
72
|
+
console.log('[mcp-ws] invalid tenant id (4404) — not retrying');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
firstDisconnectAt ??= Date.now();
|
|
76
|
+
if (Date.now() - firstDisconnectAt > RECONNECT_GIVE_UP_MS) {
|
|
77
|
+
console.log(`[mcp-ws] giving up after retrying for over ${RECONNECT_GIVE_UP_MS / 60_000} minutes`);
|
|
60
78
|
return;
|
|
61
79
|
}
|
|
62
80
|
reconnectAttempts++;
|
|
63
|
-
console.log(`[mcp-ws] retrying in
|
|
64
|
-
setTimeout(connect,
|
|
81
|
+
console.log(`[mcp-ws] retrying in ${RECONNECT_INTERVAL_MS / 1000}s (attempt ${reconnectAttempts + 1})`);
|
|
82
|
+
setTimeout(connect, RECONNECT_INTERVAL_MS);
|
|
65
83
|
};
|
|
66
84
|
ws.onerror = () => {
|
|
67
85
|
console.log('[mcp-ws] socket error (see close event for details)');
|
|
@@ -76,6 +94,12 @@ export function connectStateSocket(handlers, options = {}) {
|
|
|
76
94
|
handlers.onUpdate?.(msg.field, msg.value);
|
|
77
95
|
if (msg.type === 'call')
|
|
78
96
|
handlers.onCall?.(msg.id, msg.name, msg.args);
|
|
97
|
+
if (msg.type === 'identify') {
|
|
98
|
+
if (handlers.onIdentify)
|
|
99
|
+
handlers.onIdentify(msg.label);
|
|
100
|
+
else
|
|
101
|
+
alert(`Identify: this is the "${msg.label ?? 'unlabeled'}" connection`);
|
|
102
|
+
}
|
|
79
103
|
};
|
|
80
104
|
};
|
|
81
105
|
connect();
|
package/dist/manifest-tools.js
CHANGED
|
@@ -25,6 +25,12 @@ function manifestEntryToZodShape(entry) {
|
|
|
25
25
|
return shape;
|
|
26
26
|
}
|
|
27
27
|
const DESCRIBE_TOOLS_NAME = 'describe_tools';
|
|
28
|
+
const IDENTIFY_CONNECTION_NAME = 'identify_connection';
|
|
29
|
+
const IDENTIFY_CONNECTION_DESCRIPTION = 'Pops an alert in the browser tab behind one connection, so a human looking at several open tabs/windows ' +
|
|
30
|
+
'can tell which one this session means. Use when the user has multiple tabs bridged in and asks "which one ' +
|
|
31
|
+
'is X" or you need them to look at a specific one. Pass the connection `id` from describe_tools\' ' +
|
|
32
|
+
'`connections` array (single-connection channels can omit it). Fire-and-forget: returns immediately, does ' +
|
|
33
|
+
'not confirm the human saw it.';
|
|
28
34
|
const DESCRIBE_TOOLS_DESCRIPTION = 'Returns manifest-level context for the tools connected to THIS SESSION\'S CURRENT CHANNEL: a ' +
|
|
29
35
|
'page-authored summary (what kind of page/app this is, cross-tool sequencing rules, ' +
|
|
30
36
|
'domain concepts) plus the current list of tool names and one-line descriptions. Call ' +
|
|
@@ -111,6 +117,24 @@ export function createManifestToolRegistry(mcp, tenant) {
|
|
|
111
117
|
});
|
|
112
118
|
handles.set(DESCRIBE_TOOLS_NAME, handle);
|
|
113
119
|
}
|
|
120
|
+
function registerIdentifyConnection() {
|
|
121
|
+
const handle = mcp.registerTool(IDENTIFY_CONNECTION_NAME, {
|
|
122
|
+
description: IDENTIFY_CONNECTION_DESCRIPTION,
|
|
123
|
+
inputSchema: { id: z.string().optional().describe('Connection id from describe_tools\' `connections` array. Omit when only one connection is live.') },
|
|
124
|
+
}, async ({ id }) => {
|
|
125
|
+
const t = tenant();
|
|
126
|
+
const targetId = id ?? [...t.connections.keys()][0];
|
|
127
|
+
if (!targetId) {
|
|
128
|
+
return { content: [{ type: 'text', text: 'No live connection on this channel to identify.' }], isError: true };
|
|
129
|
+
}
|
|
130
|
+
const ok = t.identifyConnection(targetId);
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: 'text', text: ok ? `Identify signal sent to connection "${targetId}".` : `Connection "${targetId}" is not currently open.` }],
|
|
133
|
+
isError: !ok,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
handles.set(IDENTIFY_CONNECTION_NAME, handle);
|
|
137
|
+
}
|
|
114
138
|
function sync() {
|
|
115
139
|
const conns = [...tenant().connections.values()];
|
|
116
140
|
const multi = conns.length >= 2;
|
|
@@ -122,7 +146,7 @@ export function createManifestToolRegistry(mcp, tenant) {
|
|
|
122
146
|
if (multi) {
|
|
123
147
|
for (const conn of conns) {
|
|
124
148
|
for (const entry of conn.manifest) {
|
|
125
|
-
if (entry.name === DESCRIBE_TOOLS_NAME)
|
|
149
|
+
if (entry.name === DESCRIBE_TOOLS_NAME || entry.name === IDENTIFY_CONNECTION_NAME)
|
|
126
150
|
continue;
|
|
127
151
|
registeredNow.set(`${slugFor.get(conn.id)}__${entry.name}`, { connectionId: conn.id, entry });
|
|
128
152
|
}
|
|
@@ -131,14 +155,15 @@ export function createManifestToolRegistry(mcp, tenant) {
|
|
|
131
155
|
else {
|
|
132
156
|
const conn = conns[0];
|
|
133
157
|
for (const entry of tenant().toolManifest) {
|
|
134
|
-
if (entry.name === DESCRIBE_TOOLS_NAME)
|
|
158
|
+
if (entry.name === DESCRIBE_TOOLS_NAME || entry.name === IDENTIFY_CONNECTION_NAME)
|
|
135
159
|
continue;
|
|
136
160
|
registeredNow.set(entry.name, { connectionId: conn?.id, entry });
|
|
137
161
|
}
|
|
138
162
|
}
|
|
139
|
-
//
|
|
140
|
-
// below - the fixed
|
|
141
|
-
|
|
163
|
+
// Page tools named "describe_tools"/"identify_connection" would collide
|
|
164
|
+
// with the fixed tools below - the fixed ones always win so agents can
|
|
165
|
+
// rely on the name.
|
|
166
|
+
const currentNames = new Set([DESCRIBE_TOOLS_NAME, IDENTIFY_CONNECTION_NAME, ...registeredNow.keys()]);
|
|
142
167
|
for (const [name, handle] of handles) {
|
|
143
168
|
if (!currentNames.has(name)) {
|
|
144
169
|
handle.remove();
|
|
@@ -147,6 +172,8 @@ export function createManifestToolRegistry(mcp, tenant) {
|
|
|
147
172
|
}
|
|
148
173
|
if (!handles.has(DESCRIBE_TOOLS_NAME))
|
|
149
174
|
registerDescribeTools();
|
|
175
|
+
if (!handles.has(IDENTIFY_CONNECTION_NAME))
|
|
176
|
+
registerIdentifyConnection();
|
|
150
177
|
for (const [registeredName, { connectionId, entry }] of registeredNow) {
|
|
151
178
|
if (handles.has(registeredName))
|
|
152
179
|
continue;
|
package/dist/tenant.d.ts
CHANGED
|
@@ -127,6 +127,14 @@ export declare class Tenant<TSchema, TValues> {
|
|
|
127
127
|
* self-contained and cheap for the rare/short-lived window it's used in.
|
|
128
128
|
*/
|
|
129
129
|
private waitForReconnect;
|
|
130
|
+
/**
|
|
131
|
+
* Pushes an 'identify' message at one connection so its page can surface
|
|
132
|
+
* something a human watching the browser will notice (alert/sound) — lets
|
|
133
|
+
* an agent say "which tab is this" when several are bridged into one
|
|
134
|
+
* channel. Fire-and-forget like rename_connection: no ack, and silently
|
|
135
|
+
* a no-op if the connection has since closed.
|
|
136
|
+
*/
|
|
137
|
+
identifyConnection(connectionId: string): boolean;
|
|
130
138
|
resolveCall(id: string, result: unknown): void;
|
|
131
139
|
rejectCall(id: string, error: string): void;
|
|
132
140
|
touch(): void;
|
package/dist/tenant.js
CHANGED
|
@@ -261,6 +261,20 @@ export class Tenant {
|
|
|
261
261
|
poll();
|
|
262
262
|
});
|
|
263
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Pushes an 'identify' message at one connection so its page can surface
|
|
266
|
+
* something a human watching the browser will notice (alert/sound) — lets
|
|
267
|
+
* an agent say "which tab is this" when several are bridged into one
|
|
268
|
+
* channel. Fire-and-forget like rename_connection: no ack, and silently
|
|
269
|
+
* a no-op if the connection has since closed.
|
|
270
|
+
*/
|
|
271
|
+
identifyConnection(connectionId) {
|
|
272
|
+
const conn = this.connections.get(connectionId);
|
|
273
|
+
if (!conn || conn.socket.readyState !== conn.socket.OPEN)
|
|
274
|
+
return false;
|
|
275
|
+
conn.socket.send(JSON.stringify({ type: 'identify', label: conn.label }));
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
264
278
|
resolveCall(id, result) {
|
|
265
279
|
const pending = this.pendingCalls.get(id);
|
|
266
280
|
if (!pending)
|
package/dist/types.d.ts
CHANGED
package/dist/ws.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { WebSocketServer } from 'ws';
|
|
3
|
-
import { getOrCreateTenant, tenants } from './tenant.js';
|
|
3
|
+
import { getOrCreateTenant, tenants, isValidChannelName } from './tenant.js';
|
|
4
4
|
/**
|
|
5
5
|
* Ping interval for the liveness check below. Half-open sockets (client
|
|
6
6
|
* process killed/suspended without a clean TCP close — the common case for
|
|
@@ -32,16 +32,25 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
|
|
|
32
32
|
wss.on('connection', (ws, req) => {
|
|
33
33
|
const wsUrl = new URL(req.url ?? '/', `http://localhost:${port}`);
|
|
34
34
|
const requestedTenantId = wsUrl.searchParams.get('tenant');
|
|
35
|
-
if (requestedTenantId && !
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
ws.close(4404, 'Unknown or expired tenant');
|
|
35
|
+
if (requestedTenantId && !isValidChannelName(requestedTenantId)) {
|
|
36
|
+
// Never silently vivify a channel from a malformed/hostile id — same
|
|
37
|
+
// validation join_channel applies agent-side.
|
|
38
|
+
console.error(`[ws] rejected connection: invalid tenant id "${requestedTenantId}"`);
|
|
39
|
+
ws.close(4404, 'Invalid tenant id');
|
|
41
40
|
return;
|
|
42
41
|
}
|
|
43
42
|
const tenantId = requestedTenantId || 'default';
|
|
43
|
+
const recreated = !!requestedTenantId && !tenants.has(requestedTenantId);
|
|
44
44
|
const t = getOrCreateTenant(tenantId, initialSchema, initialValues);
|
|
45
|
+
if (recreated) {
|
|
46
|
+
// A page reconnecting (browser retry loop) to a named channel that no
|
|
47
|
+
// longer exists server-side — most commonly a server restart, which
|
|
48
|
+
// wipes the in-memory tenants map entirely. Recreate it on demand
|
|
49
|
+
// rather than rejecting, so the page keeps working and an agent can
|
|
50
|
+
// rejoin the same name later instead of the connection being stuck
|
|
51
|
+
// retrying forever against a channel the server will never revive.
|
|
52
|
+
console.error(`[ws] recreated previously unknown/expired tenant "${tenantId}" on reconnect`);
|
|
53
|
+
}
|
|
45
54
|
const connectionId = randomUUID();
|
|
46
55
|
t.registerConnection(connectionId, ws);
|
|
47
56
|
console.error(`[ws] connection opened: tenant=${tenantId} connection=${connectionId} (${t.connections.size} connection(s) on tenant)`);
|