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.
- 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 +53 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/manifest-tools.js +6 -2
- package/dist/mcp.d.ts +10 -1
- package/dist/mcp.js +13 -2
- package/dist/tenant.d.ts +8 -1
- package/dist/tenant.js +11 -2
- 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,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';
|
package/dist/manifest-tools.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
@@ -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',
|
|
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.
|
|
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
|
},
|