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