mcp-tenant-lib 0.1.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,53 @@
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.', {}, async () => {
38
+ const ids = [...tenants.keys()];
39
+ return { content: [{ type: 'text', text: JSON.stringify(ids, null, 2) }] };
40
+ });
41
+ mcp.tool('channel_find', 'Fuzzy-searches existing channel names for ones matching a loose query — use this when a human refers to ' +
42
+ 'a channel by topic or partial name (e.g. "the pets channel") rather than its exact name, instead of ' +
43
+ 'guessing at join_channel or falling back to eyeballing the full list_channels output yourself. Matches on ' +
44
+ 'both whole-name similarity (catches typos) and per-word similarity against underscore/hyphen-split parts ' +
45
+ 'of each name (catches "pets" matching "pet_food_memory" via the shared word "pet"). Returns a ranked list ' +
46
+ 'of {name, score} (score 0..1, higher is better) — empty if nothing scores above the threshold. Read-only: ' +
47
+ 'does NOT join or create anything, even on an exact match. Given the result(s), call join_channel yourself ' +
48
+ 'on whichever one is actually right — if there\'s one clear best match, use it directly; if several score ' +
49
+ '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 }) => {
50
+ const matches = findChannelMatches(query, [...tenants.keys()]);
51
+ return { content: [{ type: 'text', text: JSON.stringify(matches, null, 2) }] };
52
+ });
53
+ }
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';
@@ -25,7 +25,7 @@ function manifestEntryToZodShape(entry) {
25
25
  return shape;
26
26
  }
27
27
  const DESCRIBE_TOOLS_NAME = 'describe_tools';
28
- const DESCRIBE_TOOLS_DESCRIPTION = 'Returns manifest-level context for the tools this connected page registered: a ' +
28
+ const DESCRIBE_TOOLS_DESCRIPTION = 'Returns manifest-level context for the tools connected to THIS SESSION\'S CURRENT CHANNEL: a ' +
29
29
  'page-authored summary (what kind of page/app this is, cross-tool sequencing rules, ' +
30
30
  'domain concepts) plus the current list of tool names and one-line descriptions. Call ' +
31
31
  'this once after connecting, before calling any other tool from this page, so you have ' +
@@ -33,7 +33,11 @@ const DESCRIBE_TOOLS_DESCRIPTION = 'Returns manifest-level context for the tools
33
33
  'pages/tabs are connected to this session at once, tool names are prefixed per ' +
34
34
  'connection (e.g. "formalin__submit_form", "htmlpaint__clear_canvas") and this tool\'s ' +
35
35
  '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.';
36
+ 'prefix — call it whenever you\'re unsure which prefix routes to which tab. ' +
37
+ 'IMPORTANT — an empty or unexpected result here does NOT mean no page is bridged: this session may ' +
38
+ 'simply be on the wrong channel (see join_channel). If the user expects a specific bridged app/page by ' +
39
+ 'name (e.g. "the bulletino tab") and it\'s missing, call list_channels to check for a matching channel ' +
40
+ 'and join_channel to it before assuming nothing is connected.';
37
41
  /**
38
42
  * Derives a stable, unique tool-name prefix per connection: sanitized from
39
43
  * `label` (falling back to "tab" when absent or empty after sanitizing),
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
- const tenant = () => getTenant(tenantId);
5
- registerFn(mcp, tenant, port);
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
@@ -135,8 +135,15 @@ export declare class Tenant<TSchema, TValues> {
135
135
  broadcastUpdate(field: string, value: unknown): void;
136
136
  dispose(): void;
137
137
  }
138
+ /**
139
+ * URL-safe slug rule for agent-chosen channel names (see channel-tools.ts):
140
+ * letters, digits, underscore, hyphen only. Channel names become part of the
141
+ * form URL path (`/t/<id>`), so anything requiring percent-encoding is
142
+ * rejected up front rather than silently mangled.
143
+ */
144
+ declare function isValidChannelName(id: string): boolean;
138
145
  declare const tenants: Map<string, Tenant<any, any>>;
139
146
  declare function getOrCreateTenant<TSchema, TValues>(id: string, initialSchema: TSchema, initialValues: TValues): Tenant<TSchema, TValues>;
140
147
  declare function disposeTenant(id: string): void;
141
148
  declare function startIdleSweep(onSweep: (id: string) => void): NodeJS.Timeout;
142
- export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep };
149
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName };
package/dist/tenant.js CHANGED
@@ -325,6 +325,15 @@ export class Tenant {
325
325
  this.connections.clear();
326
326
  }
327
327
  }
328
+ /**
329
+ * URL-safe slug rule for agent-chosen channel names (see channel-tools.ts):
330
+ * letters, digits, underscore, hyphen only. Channel names become part of the
331
+ * form URL path (`/t/<id>`), so anything requiring percent-encoding is
332
+ * rejected up front rather than silently mangled.
333
+ */
334
+ function isValidChannelName(id) {
335
+ return /^[a-zA-Z0-9_-]+$/.test(id);
336
+ }
328
337
  const tenants = new Map();
329
338
  function getOrCreateTenant(id, initialSchema, initialValues) {
330
339
  let tenant = tenants.get(id);
@@ -345,7 +354,7 @@ function envMs(name, defaultMs) {
345
354
  const n = Number(raw);
346
355
  return Number.isFinite(n) && n > 0 ? n : defaultMs;
347
356
  }
348
- const TENANT_IDLE_TIMEOUT_MS = envMs('TENANT_IDLE_TIMEOUT_MS', 30 * 60 * 1000);
357
+ const TENANT_IDLE_TIMEOUT_MS = envMs('TENANT_IDLE_TIMEOUT_MS', 2 * 60 * 60 * 1000);
349
358
  const TENANT_SWEEP_INTERVAL_MS = envMs('TENANT_SWEEP_INTERVAL_MS', 5 * 60 * 1000);
350
359
  function startIdleSweep(onSweep) {
351
360
  const sweepInterval = setInterval(() => {
@@ -362,4 +371,4 @@ function startIdleSweep(onSweep) {
362
371
  sweepInterval.unref();
363
372
  return sweepInterval;
364
373
  }
365
- export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep };
374
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-tenant-lib",
3
- "version": "0.1.2",
3
+ "version": "0.3.3",
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
  },