mcp-tenant-lib 0.1.1 → 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
@@ -36,6 +36,26 @@ export declare class Tenant<TSchema, TValues> {
36
36
  schema: TSchema;
37
37
  store: Store<TValues>;
38
38
  submitBus: EventEmitter;
39
+ /**
40
+ * Whether some caller currently has a `submitBus.once('submit', ...)`
41
+ * listener registered — i.e. wait_for_submit / define_form({wait:true})
42
+ * is actively blocked on this tenant right now. Tracked via submitBus's
43
+ * own newListener/removeListener events (see constructor) rather than a
44
+ * manual flag, so it can never drift from the real listener count.
45
+ * Broadcast to the browser as a 'waiting' WS message so the UI can show
46
+ * whether anyone is listening, without implying Submit is meaningless
47
+ * when nobody currently is (see `submitted`).
48
+ */
49
+ waiting: boolean;
50
+ /**
51
+ * Whether the user has clicked Submit at least once since the form was
52
+ * last (re)defined. Set unconditionally on the 'submit' WS message,
53
+ * independent of `waiting` — the user can fill in and submit a form
54
+ * with no agent currently waiting (e.g. it was defined with wait:false,
55
+ * or the agent's turn ended), and a later wait_for_submit call or
56
+ * list_fields read should be able to see that they're done.
57
+ */
58
+ submitted: boolean;
39
59
  wsClients: Set<WebSocket>;
40
60
  connections: Map<string, TenantConnection>;
41
61
  lastActivityAt: number;
@@ -115,8 +135,15 @@ export declare class Tenant<TSchema, TValues> {
115
135
  broadcastUpdate(field: string, value: unknown): void;
116
136
  dispose(): void;
117
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;
118
145
  declare const tenants: Map<string, Tenant<any, any>>;
119
146
  declare function getOrCreateTenant<TSchema, TValues>(id: string, initialSchema: TSchema, initialValues: TValues): Tenant<TSchema, TValues>;
120
147
  declare function disposeTenant(id: string): void;
121
148
  declare function startIdleSweep(onSweep: (id: string) => void): NodeJS.Timeout;
122
- export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep };
149
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName };
package/dist/tenant.js CHANGED
@@ -39,6 +39,26 @@ export class Tenant {
39
39
  schema;
40
40
  store;
41
41
  submitBus;
42
+ /**
43
+ * Whether some caller currently has a `submitBus.once('submit', ...)`
44
+ * listener registered — i.e. wait_for_submit / define_form({wait:true})
45
+ * is actively blocked on this tenant right now. Tracked via submitBus's
46
+ * own newListener/removeListener events (see constructor) rather than a
47
+ * manual flag, so it can never drift from the real listener count.
48
+ * Broadcast to the browser as a 'waiting' WS message so the UI can show
49
+ * whether anyone is listening, without implying Submit is meaningless
50
+ * when nobody currently is (see `submitted`).
51
+ */
52
+ waiting = false;
53
+ /**
54
+ * Whether the user has clicked Submit at least once since the form was
55
+ * last (re)defined. Set unconditionally on the 'submit' WS message,
56
+ * independent of `waiting` — the user can fill in and submit a form
57
+ * with no agent currently waiting (e.g. it was defined with wait:false,
58
+ * or the agent's turn ended), and a later wait_for_submit call or
59
+ * list_fields read should be able to see that they're done.
60
+ */
61
+ submitted = false;
42
62
  wsClients;
43
63
  connections = new Map();
44
64
  #legacyManifest;
@@ -85,6 +105,16 @@ export class Tenant {
85
105
  this.store = new Store(initialValues);
86
106
  this.submitBus = new EventEmitter();
87
107
  this.submitBus.setMaxListeners(0);
108
+ this.submitBus.on('newListener', (event) => {
109
+ if (event !== 'submit')
110
+ return;
111
+ queueMicrotask(() => this.#syncWaiting());
112
+ });
113
+ this.submitBus.on('removeListener', (event) => {
114
+ if (event !== 'submit')
115
+ return;
116
+ this.#syncWaiting();
117
+ });
88
118
  this.wsClients = new Set();
89
119
  this.lastActivityAt = Date.now();
90
120
  this.store.onChange((field, value) => this.broadcastUpdate(field, value));
@@ -252,11 +282,12 @@ export class Tenant {
252
282
  this.store.dispose();
253
283
  this.schema = schema;
254
284
  this.store = new Store(values);
285
+ this.submitted = false;
255
286
  this.store.onChange((field, value) => this.broadcastUpdate(field, value));
256
287
  this.broadcastReinit();
257
288
  }
258
289
  broadcastReinit() {
259
- const payload = JSON.stringify({ type: 'reinit', schema: this.schema, state: this.store.snapshot() });
290
+ const payload = JSON.stringify({ type: 'reinit', schema: this.schema, state: this.store.snapshot(), waiting: this.waiting, submitted: this.submitted });
260
291
  for (const client of this.wsClients) {
261
292
  if (client.readyState === client.OPEN)
262
293
  client.send(payload);
@@ -269,6 +300,17 @@ export class Tenant {
269
300
  client.send(payload);
270
301
  }
271
302
  }
303
+ #syncWaiting() {
304
+ const nowWaiting = this.submitBus.listenerCount('submit') > 0;
305
+ if (nowWaiting === this.waiting)
306
+ return;
307
+ this.waiting = nowWaiting;
308
+ const payload = JSON.stringify({ type: 'waiting', waiting: this.waiting });
309
+ for (const client of this.wsClients) {
310
+ if (client.readyState === client.OPEN)
311
+ client.send(payload);
312
+ }
313
+ }
272
314
  dispose() {
273
315
  this.submitBus.emit('submit', { __interrupted: true, __disposed: true, ...this.store.snapshot() });
274
316
  this.store.dispose();
@@ -283,6 +325,15 @@ export class Tenant {
283
325
  this.connections.clear();
284
326
  }
285
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
+ }
286
337
  const tenants = new Map();
287
338
  function getOrCreateTenant(id, initialSchema, initialValues) {
288
339
  let tenant = tenants.get(id);
@@ -303,7 +354,7 @@ function envMs(name, defaultMs) {
303
354
  const n = Number(raw);
304
355
  return Number.isFinite(n) && n > 0 ? n : defaultMs;
305
356
  }
306
- 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);
307
358
  const TENANT_SWEEP_INTERVAL_MS = envMs('TENANT_SWEEP_INTERVAL_MS', 5 * 60 * 1000);
308
359
  function startIdleSweep(onSweep) {
309
360
  const sweepInterval = setInterval(() => {
@@ -320,4 +371,4 @@ function startIdleSweep(onSweep) {
320
371
  sweepInterval.unref();
321
372
  return sweepInterval;
322
373
  }
323
- export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep };
374
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName };
package/dist/types.d.ts CHANGED
@@ -7,10 +7,15 @@ export type ServerMessage<TSchema = unknown, TValues = unknown> = {
7
7
  type: 'init' | 'reinit';
8
8
  schema: TSchema;
9
9
  state: TValues;
10
+ waiting: boolean;
11
+ submitted: boolean;
10
12
  } | {
11
13
  type: 'update';
12
14
  field: string;
13
15
  value: unknown;
16
+ } | {
17
+ type: 'waiting';
18
+ waiting: boolean;
14
19
  } | CallMessage;
15
20
  export interface SetMessage {
16
21
  type: 'set';
package/dist/ws.js CHANGED
@@ -51,7 +51,7 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
51
51
  if (state)
52
52
  state.isAlive = true;
53
53
  });
54
- ws.send(JSON.stringify({ type: 'init', schema: t.schema, state: t.store.snapshot() }));
54
+ ws.send(JSON.stringify({ type: 'init', schema: t.schema, state: t.store.snapshot(), waiting: t.waiting, submitted: t.submitted }));
55
55
  ws.on('message', (raw) => {
56
56
  t.touch();
57
57
  let msg;
@@ -65,6 +65,7 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
65
65
  t.store.set(msg.field, msg.value);
66
66
  }
67
67
  if (msg.type === 'submit') {
68
+ t.submitted = true;
68
69
  t.submitBus.emit('submit', { __interrupted: false, ...t.store.snapshot() });
69
70
  }
70
71
  if (msg.type === 'interrupt') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-tenant-lib",
3
- "version": "0.1.1",
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
  },