mcp-tenant-lib 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anatoli Radulov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # mcp-tenant-lib
2
+
3
+ Generic tenant/session bookkeeping + MCP wiring, reusable across MCP servers that need to
4
+ track multiple browser-connected clients ("tenants") over HTTP + WebSocket.
5
+
6
+ ## Usage
7
+
8
+ ```ts
9
+ import { getOrCreateTenant, tenants, startIdleSweep, createHttpServer, attachWebSocketServer } from 'mcp-tenant-lib';
10
+ ```
11
+
12
+ Browser-side helpers (types + WebSocket bridge) are available from the `client` subpath:
13
+
14
+ ```ts
15
+ import type { ServerMessage, ClientMessage } from 'mcp-tenant-lib/client';
16
+ ```
@@ -0,0 +1,57 @@
1
+ import type { ClientMessage, ToolManifestEntry, ToolParamSpec } from './types.js';
2
+ export interface ClientAction {
3
+ name: string;
4
+ resolve: 'window' | ((args: any) => unknown | Promise<unknown>);
5
+ }
6
+ /**
7
+ * The page-authored form of a manifest entry: same shape as
8
+ * ToolManifestEntry (name/description/params/example) plus a real function
9
+ * reference. Pages build an array of these (e.g. assigned to a global like
10
+ * window.__mcpTools) and pass it to registerPageTools below — the function
11
+ * reference never goes over the wire, only the serializable fields do.
12
+ */
13
+ export interface PageToolDef {
14
+ name: string;
15
+ description: string;
16
+ params: Record<string, ToolParamSpec>;
17
+ example?: Record<string, unknown>;
18
+ fn: (args: any) => unknown | Promise<unknown>;
19
+ }
20
+ /**
21
+ * Strips `fn` from each PageToolDef to produce the wire-safe
22
+ * ToolManifestEntry[] for a RegisterToolsMessage, and returns a
23
+ * name -> fn lookup for dispatching incoming CallMessages locally.
24
+ */
25
+ export declare function splitPageTools(defs: PageToolDef[]): {
26
+ manifest: ToolManifestEntry[];
27
+ fnByName: Map<string, PageToolDef['fn']>;
28
+ };
29
+ export declare function createClientBridge(actions: ClientAction[]): {
30
+ dispatch(name: string, args: unknown): Promise<unknown>;
31
+ };
32
+ export interface StateSocketHandlers<TSchema, TValues> {
33
+ onInit?(schema: TSchema, state: TValues): void;
34
+ onReinit?(schema: TSchema, state: TValues): void;
35
+ onUpdate?(field: string, value: unknown): void;
36
+ onCall?(id: string, name: string, args: unknown): void;
37
+ onConnect?(): void;
38
+ onDisconnect?(): void;
39
+ }
40
+ export interface StateSocketOptions {
41
+ /**
42
+ * Origin of the mcp-tenant-lib instance to connect to, e.g.
43
+ * 'http://localhost:8766'. Omit when the page is served by the same
44
+ * server it's connecting to (same-origin) — defaults to location.host.
45
+ * Required for the cross-origin "AI-enable an existing page" pattern.
46
+ */
47
+ serverUrl?: string;
48
+ /**
49
+ * Explicit tenant id. Omit to fall back to parsing '/t/<id>' from
50
+ * location.pathname (same-origin pattern), else 'default'.
51
+ */
52
+ tenant?: string;
53
+ }
54
+ export declare function connectStateSocket<TSchema, TValues>(handlers: StateSocketHandlers<TSchema, TValues>, options?: StateSocketOptions): {
55
+ send(msg: ClientMessage): void;
56
+ close(): void;
57
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Strips `fn` from each PageToolDef to produce the wire-safe
3
+ * ToolManifestEntry[] for a RegisterToolsMessage, and returns a
4
+ * name -> fn lookup for dispatching incoming CallMessages locally.
5
+ */
6
+ export function splitPageTools(defs) {
7
+ const manifest = [];
8
+ const fnByName = new Map();
9
+ for (const { fn, ...entry } of defs) {
10
+ manifest.push(entry);
11
+ fnByName.set(entry.name, fn);
12
+ }
13
+ return { manifest, fnByName };
14
+ }
15
+ export function createClientBridge(actions) {
16
+ const byName = new Map(actions.map((a) => [a.name, a]));
17
+ return {
18
+ async dispatch(name, args) {
19
+ const action = byName.get(name);
20
+ if (!action)
21
+ throw new Error(`No client action registered for "${name}"`);
22
+ if (action.resolve === 'window') {
23
+ const fn = window[name];
24
+ if (typeof fn !== 'function') {
25
+ throw new Error(`window.${name} is not a function — expose it before dispatching`);
26
+ }
27
+ return fn(args);
28
+ }
29
+ return action.resolve(args);
30
+ },
31
+ };
32
+ }
33
+ export function connectStateSocket(handlers, options = {}) {
34
+ let ws;
35
+ let closedByCaller = false;
36
+ let reconnectAttempts = 0;
37
+ const connect = () => {
38
+ const tenantId = options.tenant ?? (location.pathname.startsWith('/t/')
39
+ ? location.pathname.slice('/t/'.length).split('/')[0]
40
+ : '');
41
+ const wsPath = tenantId ? `/ws?tenant=${encodeURIComponent(tenantId)}` : '/ws';
42
+ const wsOrigin = options.serverUrl
43
+ ? options.serverUrl.replace(/^http/, 'ws')
44
+ : `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
45
+ const wsUrl = `${wsOrigin}${wsPath}`;
46
+ console.log(`[mcp-ws] connecting (attempt ${reconnectAttempts + 1}): ${wsUrl}`);
47
+ ws = new WebSocket(wsUrl);
48
+ ws.onopen = () => {
49
+ console.log(`[mcp-ws] connected${reconnectAttempts > 0 ? ` after ${reconnectAttempts} reconnect attempt(s)` : ''}`);
50
+ reconnectAttempts = 0;
51
+ handlers.onConnect?.();
52
+ };
53
+ ws.onclose = (event) => {
54
+ console.log(`[mcp-ws] disconnected: code=${event.code} reason=${event.reason || '(none)'} wasClean=${event.wasClean}`);
55
+ handlers.onDisconnect?.();
56
+ if (closedByCaller)
57
+ return;
58
+ if (event.code === 4404) {
59
+ console.log('[mcp-ws] tenant unknown/expired (4404) — not retrying');
60
+ return;
61
+ }
62
+ reconnectAttempts++;
63
+ console.log(`[mcp-ws] retrying in 2s (attempt ${reconnectAttempts + 1})`);
64
+ setTimeout(connect, 2000);
65
+ };
66
+ ws.onerror = () => {
67
+ console.log('[mcp-ws] socket error (see close event for details)');
68
+ };
69
+ ws.onmessage = (event) => {
70
+ const msg = JSON.parse(event.data);
71
+ if (msg.type === 'init')
72
+ handlers.onInit?.(msg.schema, msg.state);
73
+ if (msg.type === 'reinit')
74
+ handlers.onReinit?.(msg.schema, msg.state);
75
+ if (msg.type === 'update')
76
+ handlers.onUpdate?.(msg.field, msg.value);
77
+ if (msg.type === 'call')
78
+ handlers.onCall?.(msg.id, msg.name, msg.args);
79
+ };
80
+ };
81
+ connect();
82
+ return {
83
+ send(msg) {
84
+ ws?.send(JSON.stringify(msg));
85
+ },
86
+ close() {
87
+ closedByCaller = true;
88
+ ws?.close();
89
+ },
90
+ };
91
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import http from 'node:http';
2
+ import { type RegisterToolsFn, type McpServerIdentity } from './mcp.js';
3
+ export interface CreateHttpServerOptions<TSchema, TValues> {
4
+ port: number;
5
+ staticDir: string;
6
+ initialSchema: TSchema;
7
+ initialValues: TValues;
8
+ identity: McpServerIdentity;
9
+ registerFn: RegisterToolsFn<TSchema, TValues>;
10
+ /**
11
+ * Which tenant an MCP session with no ?tenant= param on its server URL
12
+ * lands on:
13
+ *
14
+ * - 'per-session' (default): a fresh randomUUID() tenant per session,
15
+ * same as every session getting its own isolated state — what
16
+ * mcp-form relies on so concurrent agents don't share form state
17
+ * (see tenant-isolation.test.ts).
18
+ *
19
+ * - 'shared': every unpinned session lands on the single 'default'
20
+ * tenant (same one the WS side and both packages' boot-time
21
+ * getOrCreateTenant('default') call already use for plain browser
22
+ * access). Appropriate when there's exactly one browser page bridged
23
+ * per server and MCP clients aren't expected to pin a tenant
24
+ * explicitly — some clients (observed with VS Code Copilot) open a
25
+ * brand-new MCP session on every reconnect/idle DELETE cycle with no
26
+ * ?tenant=, and under 'per-session' each such reconnect mints a new,
27
+ * empty tenant that orphans whatever browser tab was already bridged
28
+ * to the previous one.
29
+ */
30
+ defaultTenantMode?: 'per-session' | 'shared';
31
+ }
32
+ export declare function createHttpServer<TSchema, TValues>({ port, staticDir, initialSchema, initialValues, identity, registerFn, defaultTenantMode }: CreateHttpServerOptions<TSchema, TValues>): http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
package/dist/http.js ADDED
@@ -0,0 +1,160 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
+ import { getOrCreateTenant } from './tenant.js';
8
+ import { buildMcpServer } from './mcp.js';
9
+ const UPLOAD_DIR = path.join(os.tmpdir(), 'mcp-form-uploads');
10
+ fs.mkdirSync(UPLOAD_DIR, { recursive: true });
11
+ const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json' };
12
+ const sessions = new Map();
13
+ export function createHttpServer({ port, staticDir, initialSchema, initialValues, identity, registerFn, defaultTenantMode = 'per-session' }) {
14
+ const getTenant = (id) => {
15
+ const t = getOrCreateTenant(id, initialSchema, initialValues);
16
+ t.touch();
17
+ return t;
18
+ };
19
+ const httpServer = http.createServer(async (req, res) => {
20
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
21
+ if (url.pathname === '/mcp') {
22
+ res.setHeader('Access-Control-Allow-Origin', '*');
23
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS');
24
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id');
25
+ if (req.method === 'OPTIONS') {
26
+ res.writeHead(204);
27
+ res.end();
28
+ return;
29
+ }
30
+ const sessionId = req.headers['mcp-session-id'];
31
+ if (req.method === 'POST' && !sessionId) {
32
+ // An MCP client that wants a stable identity across reconnects (its
33
+ // own idle timeouts, extension host restarts, etc.) can always pin
34
+ // one explicitly by including ?tenant=<id> on its configured server
35
+ // URL — same convention the WS bridge already uses. Without that,
36
+ // which tenant the session lands on depends on defaultTenantMode
37
+ // (see CreateHttpServerOptions for the tradeoff).
38
+ //
39
+ // The MCP *session id* itself is always a fresh randomUUID() —
40
+ // sessionIdGenerator must stay unique per transport (the `sessions`
41
+ // map is keyed on it) so concurrent clients don't collide; only
42
+ // which *tenant* the session operates on varies by mode.
43
+ const requestedTenantId = url.searchParams.get('tenant');
44
+ const tenantId = requestedTenantId || (defaultTenantMode === 'shared' ? 'default' : randomUUID());
45
+ const transport = new StreamableHTTPServerTransport({
46
+ sessionIdGenerator: () => randomUUID(),
47
+ onsessioninitialized: (id) => {
48
+ sessions.set(id, transport);
49
+ getOrCreateTenant(tenantId, initialSchema, initialValues);
50
+ console.error(`[mcp] session opened: ${id} (tenant=${tenantId})`);
51
+ },
52
+ });
53
+ transport.onclose = () => {
54
+ // The transport closing only means this particular HTTP/SSE
55
+ // connection ended — it does NOT mean the tenant (and any
56
+ // browser tabs bridged to it over /ws) should be torn down.
57
+ // Disposing here used to force-close every connected browser
58
+ // tab the instant an MCP client reconnected/recycled its
59
+ // connection, even though the tenant itself was still healthy.
60
+ // Tenant disposal is now solely driven by the idle sweep (see
61
+ // startIdleSweep) or an explicit DELETE (below), so a tenant —
62
+ // and its live browser connections — survives MCP-side session
63
+ // churn as long as it keeps seeing activity from either side.
64
+ if (transport.sessionId) {
65
+ sessions.delete(transport.sessionId);
66
+ console.error(`[mcp] session detached: ${transport.sessionId}`);
67
+ }
68
+ };
69
+ const mcpInstance = buildMcpServer(identity, tenantId, getTenant, port, registerFn);
70
+ await mcpInstance.connect(transport);
71
+ await transport.handleRequest(req, res);
72
+ return;
73
+ }
74
+ if (sessionId && sessions.has(sessionId)) {
75
+ if (req.method === 'DELETE') {
76
+ // Mirrors transport.onclose above: a client-initiated DELETE ends
77
+ // *this* MCP session, but some clients (observed with Copilot)
78
+ // send it routinely between turns / on idle, not just on final
79
+ // teardown. Disposing the tenant here used to force-close every
80
+ // bridged browser tab and wipe its tool manifest on every such
81
+ // DELETE, which surfaced to the agent as tools vanishing
82
+ // ("Tool X not found") the moment it tried to call something
83
+ // right after a DELETE-triggered reconnect cycle. Tenant
84
+ // disposal is left to the idle sweep (see startIdleSweep) so
85
+ // browser state survives MCP-side session churn from either
86
+ // close path — the DELETE still reaches the transport below so
87
+ // its own session bookkeeping (and transport.onclose, which
88
+ // removes it from `sessions`) runs normally.
89
+ console.error(`[mcp] session closing (explicit DELETE): ${sessionId}`);
90
+ }
91
+ await sessions.get(sessionId).handleRequest(req, res);
92
+ return;
93
+ }
94
+ console.error(`[mcp] rejected ${req.method} for unknown session: ${sessionId ?? '(none)'}${url.searchParams.get('tenant') ? ` tenant=${url.searchParams.get('tenant')}` : ''}`);
95
+ res.writeHead(404);
96
+ res.end('Unknown session');
97
+ return;
98
+ }
99
+ if (url.pathname === '/upload' && req.method === 'POST') {
100
+ const contentType = req.headers['content-type'] ?? '';
101
+ const boundaryMatch = contentType.match(/boundary=(.+)$/);
102
+ if (!boundaryMatch) {
103
+ res.writeHead(400);
104
+ res.end('Missing boundary');
105
+ return;
106
+ }
107
+ const boundary = Buffer.from('--' + boundaryMatch[1]);
108
+ const chunks = [];
109
+ req.on('data', (chunk) => chunks.push(chunk));
110
+ req.on('end', () => {
111
+ const body = Buffer.concat(chunks);
112
+ // Find the filename from Content-Disposition header in the part
113
+ const headerEnd = body.indexOf('\r\n\r\n');
114
+ const headerSection = body.slice(0, headerEnd).toString();
115
+ const nameMatch = headerSection.match(/filename="([^"]+)"/);
116
+ const originalName = nameMatch ? nameMatch[1] : 'upload';
117
+ const ext = path.extname(originalName);
118
+ const savedName = `${randomUUID()}${ext}`;
119
+ const savedPath = path.join(UPLOAD_DIR, savedName);
120
+ // Extract file bytes: after \r\n\r\n, before the closing boundary
121
+ const fileStart = headerEnd + 4;
122
+ const closingBoundary = Buffer.from('\r\n' + boundary.toString() + '--');
123
+ let fileEnd = body.length;
124
+ for (let i = fileStart; i <= body.length - closingBoundary.length; i++) {
125
+ if (body.slice(i, i + closingBoundary.length).equals(closingBoundary)) {
126
+ fileEnd = i;
127
+ break;
128
+ }
129
+ }
130
+ fs.writeFile(savedPath, body.slice(fileStart, fileEnd), (err) => {
131
+ if (err) {
132
+ res.writeHead(500);
133
+ res.end('Write error');
134
+ return;
135
+ }
136
+ res.writeHead(200, { 'Content-Type': 'application/json' });
137
+ res.end(JSON.stringify({ path: savedPath, name: originalName }));
138
+ });
139
+ });
140
+ return;
141
+ }
142
+ let pathname = url.pathname;
143
+ if (pathname.startsWith('/t/')) {
144
+ pathname = '/';
145
+ }
146
+ let filePath = pathname === '/' ? '/index.html' : pathname;
147
+ filePath = path.join(staticDir, filePath);
148
+ fs.readFile(filePath, (err, data) => {
149
+ if (err) {
150
+ res.writeHead(404);
151
+ res.end('Not found');
152
+ return;
153
+ }
154
+ const ext = path.extname(filePath);
155
+ res.writeHead(200, { 'Content-Type': mime[ext] || 'application/octet-stream', 'Access-Control-Allow-Origin': '*' });
156
+ res.end(data);
157
+ });
158
+ });
159
+ return httpServer;
160
+ }
@@ -0,0 +1,6 @@
1
+ export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep } from './tenant.js';
2
+ export { buildMcpServer, type RegisterToolsFn, type McpServerIdentity } from './mcp.js';
3
+ export { createHttpServer, type CreateHttpServerOptions } from './http.js';
4
+ export { attachWebSocketServer } from './ws.js';
5
+ export { createManifestToolRegistry, type ManifestToolRegistry } from './manifest-tools.js';
6
+ export * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep } from './tenant.js';
2
+ export { buildMcpServer } from './mcp.js';
3
+ export { createHttpServer } from './http.js';
4
+ export { attachWebSocketServer } from './ws.js';
5
+ export { createManifestToolRegistry } from './manifest-tools.js';
6
+ export * from './types.js';
@@ -0,0 +1,21 @@
1
+ import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { Tenant } from './tenant.js';
3
+ export interface ManifestToolRegistry {
4
+ handles: Map<string, RegisteredTool>;
5
+ sync(): void;
6
+ }
7
+ /**
8
+ * Registers one MCP tool per entry in tenant().toolManifest, dispatching
9
+ * calls to the page via tenant().call(target, args). Also always registers
10
+ * a fixed `describe_tools` tool that surfaces tenant().toolManifestSummary -
11
+ * the page-authored manifest-level context from RegisterToolsMessage.summary
12
+ * - plus a compact index of current tool names/descriptions. That summary
13
+ * can't be baked into the McpServer's static `instructions` because the
14
+ * page (and its manifest/summary) only connects and registers *after* the
15
+ * McpServer is already constructed per session (see http.ts) - describe_tools
16
+ * is the one mechanism that can carry page-supplied context to the agent.
17
+ * Call sync() again after the manifest changes (e.g. on a fresh
18
+ * register_tools push) to remove stale tools and register new ones - each
19
+ * mutation trips the SDK's own tools/list_changed notification automatically.
20
+ */
21
+ export declare function createManifestToolRegistry<TSchema, TValues>(mcp: McpServer, tenant: () => Tenant<TSchema, TValues>): ManifestToolRegistry;
@@ -0,0 +1,162 @@
1
+ import { z } from 'zod';
2
+ function paramSpecToZod(spec) {
3
+ let schema;
4
+ switch (spec.type) {
5
+ case 'string':
6
+ schema = z.string();
7
+ break;
8
+ case 'number':
9
+ schema = z.number();
10
+ break;
11
+ case 'boolean':
12
+ schema = z.boolean();
13
+ break;
14
+ default: throw new Error(`unsupported param type "${spec.type}" (supported: string, number, boolean)`);
15
+ }
16
+ if (spec.description)
17
+ schema = schema.describe(spec.description);
18
+ return spec.optional ? schema.optional() : schema;
19
+ }
20
+ function manifestEntryToZodShape(entry) {
21
+ const shape = {};
22
+ for (const [key, spec] of Object.entries(entry.params)) {
23
+ shape[key] = paramSpecToZod(spec);
24
+ }
25
+ return shape;
26
+ }
27
+ const DESCRIBE_TOOLS_NAME = 'describe_tools';
28
+ const DESCRIBE_TOOLS_DESCRIPTION = 'Returns manifest-level context for the tools this connected page registered: a ' +
29
+ 'page-authored summary (what kind of page/app this is, cross-tool sequencing rules, ' +
30
+ 'domain concepts) plus the current list of tool names and one-line descriptions. Call ' +
31
+ 'this once after connecting, before calling any other tool from this page, so you have ' +
32
+ 'the shared context that individual tool descriptions don\'t repeat. When multiple ' +
33
+ 'pages/tabs are connected to this session at once, tool names are prefixed per ' +
34
+ 'connection (e.g. "formalin__submit_form", "htmlpaint__clear_canvas") and this tool\'s ' +
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.';
37
+ /**
38
+ * Derives a stable, unique tool-name prefix per connection: sanitized from
39
+ * `label` (falling back to "tab" when absent or empty after sanitizing),
40
+ * with a 1-based ordinal appended on collision (first connection to open
41
+ * keeps the bare slug; later ones sharing that slug get "2", "3", ...).
42
+ * Recomputed fresh on every call from `connections`' current iteration
43
+ * order (== connection-open order, since Map preserves insertion order and
44
+ * entries are only ever added/removed, never reordered) — no state to
45
+ * keep in sync separately.
46
+ */
47
+ function computeSlugs(connections) {
48
+ const slugFor = new Map();
49
+ const countSoFar = new Map();
50
+ for (const conn of connections) {
51
+ const base = slugify(conn.label);
52
+ const n = (countSoFar.get(base) ?? 0) + 1;
53
+ countSoFar.set(base, n);
54
+ slugFor.set(conn.id, n === 1 ? base : `${base}${n}`);
55
+ }
56
+ return slugFor;
57
+ }
58
+ function slugify(label) {
59
+ const cleaned = (label ?? '')
60
+ .toLowerCase()
61
+ .replace(/[^a-z0-9]+/g, '_')
62
+ .replace(/^_+|_+$/g, '')
63
+ .slice(0, 24);
64
+ return cleaned || 'tab';
65
+ }
66
+ /**
67
+ * Registers one MCP tool per entry in tenant().toolManifest, dispatching
68
+ * calls to the page via tenant().call(target, args). Also always registers
69
+ * a fixed `describe_tools` tool that surfaces tenant().toolManifestSummary -
70
+ * the page-authored manifest-level context from RegisterToolsMessage.summary
71
+ * - plus a compact index of current tool names/descriptions. That summary
72
+ * can't be baked into the McpServer's static `instructions` because the
73
+ * page (and its manifest/summary) only connects and registers *after* the
74
+ * McpServer is already constructed per session (see http.ts) - describe_tools
75
+ * is the one mechanism that can carry page-supplied context to the agent.
76
+ * Call sync() again after the manifest changes (e.g. on a fresh
77
+ * register_tools push) to remove stale tools and register new ones - each
78
+ * mutation trips the SDK's own tools/list_changed notification automatically.
79
+ */
80
+ export function createManifestToolRegistry(mcp, tenant) {
81
+ const handles = new Map();
82
+ function registerDescribeTools() {
83
+ const handle = mcp.registerTool(DESCRIBE_TOOLS_NAME, { description: DESCRIBE_TOOLS_DESCRIPTION, inputSchema: {} }, async () => {
84
+ const t = tenant();
85
+ const conns = [...t.connections.values()];
86
+ if (conns.length <= 1) {
87
+ const payload = {
88
+ summary: t.toolManifestSummary ?? null,
89
+ tools: t.toolManifest.map((e) => ({ name: e.name, description: e.description })),
90
+ };
91
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
92
+ }
93
+ const slugFor = computeSlugs(conns);
94
+ const payload = {
95
+ connections: conns.map((c) => ({
96
+ id: c.id,
97
+ label: c.label ?? null,
98
+ toolPrefix: slugFor.get(c.id),
99
+ summary: c.summary ?? null,
100
+ tools: c.manifest.map((e) => ({
101
+ name: `${slugFor.get(c.id)}__${e.name}`,
102
+ description: e.description,
103
+ })),
104
+ })),
105
+ };
106
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
107
+ });
108
+ handles.set(DESCRIBE_TOOLS_NAME, handle);
109
+ }
110
+ function sync() {
111
+ const conns = [...tenant().connections.values()];
112
+ const multi = conns.length >= 2;
113
+ const slugFor = multi ? computeSlugs(conns) : undefined;
114
+ // registeredName -> which connection/entry it dispatches to. With a
115
+ // single (or no) connection, registeredName === entry.name, exactly
116
+ // like before multi-connection support existed.
117
+ const registeredNow = new Map();
118
+ if (multi) {
119
+ for (const conn of conns) {
120
+ for (const entry of conn.manifest) {
121
+ if (entry.name === DESCRIBE_TOOLS_NAME)
122
+ continue;
123
+ registeredNow.set(`${slugFor.get(conn.id)}__${entry.name}`, { connectionId: conn.id, entry });
124
+ }
125
+ }
126
+ }
127
+ else {
128
+ const conn = conns[0];
129
+ for (const entry of tenant().toolManifest) {
130
+ if (entry.name === DESCRIBE_TOOLS_NAME)
131
+ continue;
132
+ registeredNow.set(entry.name, { connectionId: conn?.id, entry });
133
+ }
134
+ }
135
+ // A page tool named "describe_tools" would collide with the fixed tool
136
+ // below - the fixed one always wins so agents can rely on the name.
137
+ const currentNames = new Set([DESCRIBE_TOOLS_NAME, ...registeredNow.keys()]);
138
+ for (const [name, handle] of handles) {
139
+ if (!currentNames.has(name)) {
140
+ handle.remove();
141
+ handles.delete(name);
142
+ }
143
+ }
144
+ if (!handles.has(DESCRIBE_TOOLS_NAME))
145
+ registerDescribeTools();
146
+ for (const [registeredName, { connectionId, entry }] of registeredNow) {
147
+ if (handles.has(registeredName))
148
+ continue;
149
+ const handle = mcp.registerTool(registeredName, { description: entry.description, inputSchema: manifestEntryToZodShape(entry) }, async (args) => {
150
+ try {
151
+ const result = await tenant().call(connectionId, entry.name, args);
152
+ return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] };
153
+ }
154
+ catch (err) {
155
+ return { content: [{ type: 'text', text: String(err.message) }], isError: true };
156
+ }
157
+ });
158
+ handles.set(registeredName, handle);
159
+ }
160
+ }
161
+ return { handles, sync };
162
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { Tenant } from './tenant.js';
3
+ export type RegisterToolsFn<TSchema = any, TValues = any> = (mcp: McpServer, tenant: () => Tenant<TSchema, TValues>, port: number) => void;
4
+ export interface McpServerIdentity {
5
+ name: string;
6
+ version: string;
7
+ }
8
+ 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 ADDED
@@ -0,0 +1,7 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export function buildMcpServer(identity, tenantId, getTenant, port, registerFn) {
3
+ const mcp = new McpServer(identity);
4
+ const tenant = () => getTenant(tenantId);
5
+ registerFn(mcp, tenant, port);
6
+ return mcp;
7
+ }
@@ -0,0 +1,122 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { WebSocket } from 'ws';
3
+ import type { ToolManifestEntry } from './types.js';
4
+ /**
5
+ * One WS connection's own tool manifest/summary/label, addressable
6
+ * independently of other connections sharing the same tenant (e.g. two
7
+ * browser tabs pasted the same embed snippet). See manifest-tools.ts for
8
+ * how multiple connections' tools get name-disambiguated.
9
+ */
10
+ export interface TenantConnection {
11
+ id: string;
12
+ socket: WebSocket;
13
+ label?: string;
14
+ manifest: ToolManifestEntry[];
15
+ summary?: string;
16
+ }
17
+ export declare class Store<TValues> {
18
+ #private;
19
+ constructor(initial: TValues);
20
+ has(name: string): boolean;
21
+ get(name: string): unknown;
22
+ set(name: string, value: unknown): void;
23
+ snapshot(): TValues;
24
+ onChange(fn: (name: string, value: unknown) => void): () => boolean;
25
+ dispose(): void;
26
+ }
27
+ /**
28
+ * TSchema is set once per applyState call and is not itself reactive
29
+ * (e.g. field definitions/labels). TValues is the reactive value bag
30
+ * backing `store`, broadcast field-by-field over the WS `update` message.
31
+ * Consumers with no separate schema concept can pass `undefined` for TSchema.
32
+ */
33
+ export declare class Tenant<TSchema, TValues> {
34
+ #private;
35
+ id: string;
36
+ schema: TSchema;
37
+ store: Store<TValues>;
38
+ submitBus: EventEmitter;
39
+ wsClients: Set<WebSocket>;
40
+ connections: Map<string, TenantConnection>;
41
+ lastActivityAt: number;
42
+ pendingCalls: Map<string, {
43
+ resolve: (v: unknown) => void;
44
+ reject: (e: Error) => void;
45
+ }>;
46
+ /**
47
+ * Back-compat view over the per-connection manifests below: single flat
48
+ * array/summary, meaningful for the 0-1-connection case (the overwhelming
49
+ * majority — a single page/tab on this tenant). With 2+ connections this
50
+ * flattens everything, which loses which tool belongs to which
51
+ * connection — multi-connection-aware code (manifest-tools.ts) reads
52
+ * `connections` directly instead.
53
+ */
54
+ get toolManifest(): ToolManifestEntry[];
55
+ get toolManifestSummary(): string | undefined;
56
+ constructor(id: string, initialSchema: TSchema, initialValues: TValues);
57
+ /**
58
+ * Legacy/no-WS path: registers a manifest with no real connection behind
59
+ * it. Used directly by tests that construct a bare Tenant, and left in
60
+ * place for any caller that doesn't (yet) know about individual
61
+ * connections. Real WS-driven registration goes through
62
+ * registerConnection/updateConnectionManifest instead (see ws.ts).
63
+ */
64
+ setToolManifest(manifest: ToolManifestEntry[], summary?: string): void;
65
+ registerConnection(id: string, socket: WebSocket): void;
66
+ updateConnectionManifest(id: string, manifest: ToolManifestEntry[], summary?: string, label?: string): void;
67
+ /**
68
+ * Updates only a connection's display label, leaving its manifest/summary
69
+ * untouched — used by RenameConnectionMessage so a page can rename itself
70
+ * (e.g. via __mcpRename) without resending its whole tool manifest.
71
+ * Re-syncs so tool-name prefixes (computeSlugs in manifest-tools.ts)
72
+ * reflect the new label immediately.
73
+ */
74
+ renameConnection(id: string, label: string): void;
75
+ removeConnection(id: string): void;
76
+ addManifestToolRegistry(registry: {
77
+ sync(): void;
78
+ }): void;
79
+ removeManifestToolRegistry(registry: {
80
+ sync(): void;
81
+ }): void;
82
+ syncManifestToolRegistries(): void;
83
+ /**
84
+ * `connectionId` targets the call at one specific connection's socket —
85
+ * this is what lets multiple tabs share a tenant without racing on each
86
+ * other's responses. Passing `undefined` broadcasts to every socket on
87
+ * the tenant, same as the old behavior; kept for the legacy/no-connections
88
+ * test path.
89
+ *
90
+ * A reconnecting tab always gets a brand-new `connectionId` (see ws.ts),
91
+ * so a call already in flight when its socket drops has no old id to
92
+ * reconnect to. Rather than failing it immediately (the client's own 2s
93
+ * reconnect loop — see client-bridge.ts — would very likely have
94
+ * succeeded a moment later), we give it a short grace window to see if
95
+ * *some* connection reappears on this tenant before giving up. This only
96
+ * matters for the target-one-connection path: with a single-tab tenant
97
+ * (the overwhelming common case) any reappearing connection is the same
98
+ * tab; with multiple tabs the caller only reaches this path once the
99
+ * specific one it wanted has already vanished, so re-targeting the sole
100
+ * survivor (if there's exactly one) is still the best available guess.
101
+ */
102
+ call(connectionId: string | undefined, name: string, args: unknown, timeoutMs?: number, reconnectGraceMs?: number): Promise<unknown>;
103
+ /**
104
+ * Resolves with a live connection id once `this.connections` becomes
105
+ * non-empty again within `graceMs`, or `undefined` on timeout. Polls
106
+ * instead of hooking registerConnection directly to keep this
107
+ * self-contained and cheap for the rare/short-lived window it's used in.
108
+ */
109
+ private waitForReconnect;
110
+ resolveCall(id: string, result: unknown): void;
111
+ rejectCall(id: string, error: string): void;
112
+ touch(): void;
113
+ applyState(schema: TSchema, values: TValues): void;
114
+ broadcastReinit(): void;
115
+ broadcastUpdate(field: string, value: unknown): void;
116
+ dispose(): void;
117
+ }
118
+ declare const tenants: Map<string, Tenant<any, any>>;
119
+ declare function getOrCreateTenant<TSchema, TValues>(id: string, initialSchema: TSchema, initialValues: TValues): Tenant<TSchema, TValues>;
120
+ declare function disposeTenant(id: string): void;
121
+ declare function startIdleSweep(onSweep: (id: string) => void): NodeJS.Timeout;
122
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep };
package/dist/tenant.js ADDED
@@ -0,0 +1,323 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { randomUUID } from 'node:crypto';
3
+ /**
4
+ * How long Tenant.call waits for some connection to reappear on a tenant
5
+ * before giving up, when the connection it was targeting has already
6
+ * vanished (see Tenant.call). Set comfortably above the client's 2s
7
+ * reconnect retry (client-bridge.ts) so a single dropped-then-reconnected
8
+ * socket doesn't surface as a failed call.
9
+ */
10
+ const RECONNECT_GRACE_MS = 4_000;
11
+ export class Store {
12
+ #values;
13
+ #subscribers = new Set();
14
+ constructor(initial) {
15
+ this.#values = initial;
16
+ }
17
+ has(name) { return Object.prototype.hasOwnProperty.call(this.#values, name); }
18
+ get(name) { return this.#values[name]; }
19
+ set(name, value) {
20
+ this.#values[name] = value;
21
+ for (const fn of this.#subscribers)
22
+ fn(name, value);
23
+ }
24
+ snapshot() { return { ...this.#values }; }
25
+ onChange(fn) {
26
+ this.#subscribers.add(fn);
27
+ return () => this.#subscribers.delete(fn);
28
+ }
29
+ dispose() { this.#subscribers.clear(); }
30
+ }
31
+ /**
32
+ * TSchema is set once per applyState call and is not itself reactive
33
+ * (e.g. field definitions/labels). TValues is the reactive value bag
34
+ * backing `store`, broadcast field-by-field over the WS `update` message.
35
+ * Consumers with no separate schema concept can pass `undefined` for TSchema.
36
+ */
37
+ export class Tenant {
38
+ id;
39
+ schema;
40
+ store;
41
+ submitBus;
42
+ wsClients;
43
+ connections = new Map();
44
+ #legacyManifest;
45
+ #legacySummary;
46
+ lastActivityAt;
47
+ pendingCalls = new Map();
48
+ /**
49
+ * One entry per MCP session currently bound to this tenant (each session
50
+ * builds its own McpServer + registry via registerFn — see register.ts).
51
+ * Under defaultTenantMode: 'shared' (http.ts), multiple concurrent MCP
52
+ * sessions legitimately share one tenant, so this can't be a single
53
+ * slot: overwriting it on each new session used to silently stop
54
+ * syncing every *other* session's tool list on the next WS manifest
55
+ * change, freezing their tools/list stale. addManifestToolRegistry /
56
+ * removeManifestToolRegistry (called from mcp.ts around session
57
+ * connect/close) keep this in sync with which sessions are live;
58
+ * syncManifestToolRegistries() (called wherever the single sync() call
59
+ * used to happen) fans out to all of them.
60
+ */
61
+ #manifestToolRegistries = new Set();
62
+ /**
63
+ * Back-compat view over the per-connection manifests below: single flat
64
+ * array/summary, meaningful for the 0-1-connection case (the overwhelming
65
+ * majority — a single page/tab on this tenant). With 2+ connections this
66
+ * flattens everything, which loses which tool belongs to which
67
+ * connection — multi-connection-aware code (manifest-tools.ts) reads
68
+ * `connections` directly instead.
69
+ */
70
+ get toolManifest() {
71
+ if (this.connections.size === 0)
72
+ return this.#legacyManifest ?? [];
73
+ if (this.connections.size === 1)
74
+ return [...this.connections.values()][0].manifest;
75
+ return [...this.connections.values()].flatMap((c) => c.manifest);
76
+ }
77
+ get toolManifestSummary() {
78
+ if (this.connections.size === 1)
79
+ return [...this.connections.values()][0].summary;
80
+ return this.#legacySummary;
81
+ }
82
+ constructor(id, initialSchema, initialValues) {
83
+ this.id = id;
84
+ this.schema = initialSchema;
85
+ this.store = new Store(initialValues);
86
+ this.submitBus = new EventEmitter();
87
+ this.submitBus.setMaxListeners(0);
88
+ this.wsClients = new Set();
89
+ this.lastActivityAt = Date.now();
90
+ this.store.onChange((field, value) => this.broadcastUpdate(field, value));
91
+ }
92
+ /**
93
+ * Legacy/no-WS path: registers a manifest with no real connection behind
94
+ * it. Used directly by tests that construct a bare Tenant, and left in
95
+ * place for any caller that doesn't (yet) know about individual
96
+ * connections. Real WS-driven registration goes through
97
+ * registerConnection/updateConnectionManifest instead (see ws.ts).
98
+ */
99
+ setToolManifest(manifest, summary) {
100
+ this.#legacyManifest = manifest;
101
+ this.#legacySummary = summary;
102
+ this.syncManifestToolRegistries();
103
+ }
104
+ registerConnection(id, socket) {
105
+ this.connections.set(id, { id, socket, manifest: [], summary: undefined, label: undefined });
106
+ this.wsClients.add(socket);
107
+ }
108
+ updateConnectionManifest(id, manifest, summary, label) {
109
+ const conn = this.connections.get(id);
110
+ if (!conn)
111
+ return; // connection closed/unknown — ignore a late message
112
+ conn.manifest = manifest;
113
+ conn.summary = summary;
114
+ conn.label = label;
115
+ this.syncManifestToolRegistries();
116
+ }
117
+ /**
118
+ * Updates only a connection's display label, leaving its manifest/summary
119
+ * untouched — used by RenameConnectionMessage so a page can rename itself
120
+ * (e.g. via __mcpRename) without resending its whole tool manifest.
121
+ * Re-syncs so tool-name prefixes (computeSlugs in manifest-tools.ts)
122
+ * reflect the new label immediately.
123
+ */
124
+ renameConnection(id, label) {
125
+ const conn = this.connections.get(id);
126
+ if (!conn)
127
+ return; // connection closed/unknown — ignore a late message
128
+ conn.label = label;
129
+ this.syncManifestToolRegistries();
130
+ }
131
+ removeConnection(id) {
132
+ const conn = this.connections.get(id);
133
+ if (conn)
134
+ this.wsClients.delete(conn.socket);
135
+ this.connections.delete(id);
136
+ this.syncManifestToolRegistries();
137
+ }
138
+ addManifestToolRegistry(registry) {
139
+ this.#manifestToolRegistries.add(registry);
140
+ registry.sync();
141
+ }
142
+ removeManifestToolRegistry(registry) {
143
+ this.#manifestToolRegistries.delete(registry);
144
+ }
145
+ syncManifestToolRegistries() {
146
+ for (const registry of this.#manifestToolRegistries)
147
+ registry.sync();
148
+ }
149
+ /**
150
+ * `connectionId` targets the call at one specific connection's socket —
151
+ * this is what lets multiple tabs share a tenant without racing on each
152
+ * other's responses. Passing `undefined` broadcasts to every socket on
153
+ * the tenant, same as the old behavior; kept for the legacy/no-connections
154
+ * test path.
155
+ *
156
+ * A reconnecting tab always gets a brand-new `connectionId` (see ws.ts),
157
+ * so a call already in flight when its socket drops has no old id to
158
+ * reconnect to. Rather than failing it immediately (the client's own 2s
159
+ * reconnect loop — see client-bridge.ts — would very likely have
160
+ * succeeded a moment later), we give it a short grace window to see if
161
+ * *some* connection reappears on this tenant before giving up. This only
162
+ * matters for the target-one-connection path: with a single-tab tenant
163
+ * (the overwhelming common case) any reappearing connection is the same
164
+ * tab; with multiple tabs the caller only reaches this path once the
165
+ * specific one it wanted has already vanished, so re-targeting the sole
166
+ * survivor (if there's exactly one) is still the best available guess.
167
+ */
168
+ call(connectionId, name, args, timeoutMs = 10_000, reconnectGraceMs = RECONNECT_GRACE_MS) {
169
+ const id = randomUUID();
170
+ const promise = new Promise((resolve, reject) => {
171
+ this.pendingCalls.set(id, { resolve, reject });
172
+ const timer = setTimeout(() => {
173
+ if (this.pendingCalls.delete(id))
174
+ reject(new Error(`call to "${name}" timed out after ${timeoutMs}ms`));
175
+ }, timeoutMs);
176
+ timer.unref();
177
+ });
178
+ const send = (targetConnectionId) => {
179
+ const payload = { type: 'call', id, name, args };
180
+ const raw = JSON.stringify(payload);
181
+ if (targetConnectionId) {
182
+ const conn = this.connections.get(targetConnectionId);
183
+ if (conn && conn.socket.readyState === conn.socket.OPEN)
184
+ conn.socket.send(raw);
185
+ }
186
+ else {
187
+ for (const client of this.wsClients) {
188
+ if (client.readyState === client.OPEN)
189
+ client.send(raw);
190
+ }
191
+ }
192
+ };
193
+ if (connectionId && !this.connections.has(connectionId)) {
194
+ this.waitForReconnect(reconnectGraceMs).then((revivedConnectionId) => {
195
+ const pending = this.pendingCalls.get(id);
196
+ if (!pending)
197
+ return; // already timed out/resolved while we waited
198
+ if (!revivedConnectionId) {
199
+ this.pendingCalls.delete(id);
200
+ pending.reject(new Error(`connection "${connectionId}" is no longer connected`));
201
+ return;
202
+ }
203
+ send(revivedConnectionId);
204
+ });
205
+ return promise;
206
+ }
207
+ send(connectionId);
208
+ return promise;
209
+ }
210
+ /**
211
+ * Resolves with a live connection id once `this.connections` becomes
212
+ * non-empty again within `graceMs`, or `undefined` on timeout. Polls
213
+ * instead of hooking registerConnection directly to keep this
214
+ * self-contained and cheap for the rare/short-lived window it's used in.
215
+ */
216
+ waitForReconnect(graceMs) {
217
+ return new Promise((resolve) => {
218
+ const deadline = Date.now() + graceMs;
219
+ const poll = () => {
220
+ const [first] = this.connections.keys();
221
+ if (first) {
222
+ resolve(first);
223
+ return;
224
+ }
225
+ if (Date.now() >= deadline) {
226
+ resolve(undefined);
227
+ return;
228
+ }
229
+ setTimeout(poll, 250).unref();
230
+ };
231
+ poll();
232
+ });
233
+ }
234
+ resolveCall(id, result) {
235
+ const pending = this.pendingCalls.get(id);
236
+ if (!pending)
237
+ return;
238
+ this.pendingCalls.delete(id);
239
+ pending.resolve(result);
240
+ }
241
+ rejectCall(id, error) {
242
+ const pending = this.pendingCalls.get(id);
243
+ if (!pending)
244
+ return;
245
+ this.pendingCalls.delete(id);
246
+ pending.reject(new Error(error));
247
+ }
248
+ touch() {
249
+ this.lastActivityAt = Date.now();
250
+ }
251
+ applyState(schema, values) {
252
+ this.store.dispose();
253
+ this.schema = schema;
254
+ this.store = new Store(values);
255
+ this.store.onChange((field, value) => this.broadcastUpdate(field, value));
256
+ this.broadcastReinit();
257
+ }
258
+ broadcastReinit() {
259
+ const payload = JSON.stringify({ type: 'reinit', schema: this.schema, state: this.store.snapshot() });
260
+ for (const client of this.wsClients) {
261
+ if (client.readyState === client.OPEN)
262
+ client.send(payload);
263
+ }
264
+ }
265
+ broadcastUpdate(field, value) {
266
+ const payload = JSON.stringify({ type: 'update', field, value });
267
+ for (const client of this.wsClients) {
268
+ if (client.readyState === client.OPEN)
269
+ client.send(payload);
270
+ }
271
+ }
272
+ dispose() {
273
+ this.submitBus.emit('submit', { __interrupted: true, __disposed: true, ...this.store.snapshot() });
274
+ this.store.dispose();
275
+ this.submitBus.removeAllListeners();
276
+ for (const [, pending] of this.pendingCalls) {
277
+ pending.reject(new Error('tenant disposed'));
278
+ }
279
+ this.pendingCalls.clear();
280
+ for (const client of this.wsClients)
281
+ client.close();
282
+ this.wsClients.clear();
283
+ this.connections.clear();
284
+ }
285
+ }
286
+ const tenants = new Map();
287
+ function getOrCreateTenant(id, initialSchema, initialValues) {
288
+ let tenant = tenants.get(id);
289
+ if (!tenant) {
290
+ tenant = new Tenant(id, initialSchema, initialValues);
291
+ tenants.set(id, tenant);
292
+ }
293
+ return tenant;
294
+ }
295
+ function disposeTenant(id) {
296
+ tenants.get(id)?.dispose();
297
+ tenants.delete(id);
298
+ }
299
+ function envMs(name, defaultMs) {
300
+ const raw = process.env[name];
301
+ if (!raw)
302
+ return defaultMs;
303
+ const n = Number(raw);
304
+ return Number.isFinite(n) && n > 0 ? n : defaultMs;
305
+ }
306
+ const TENANT_IDLE_TIMEOUT_MS = envMs('TENANT_IDLE_TIMEOUT_MS', 30 * 60 * 1000);
307
+ const TENANT_SWEEP_INTERVAL_MS = envMs('TENANT_SWEEP_INTERVAL_MS', 5 * 60 * 1000);
308
+ function startIdleSweep(onSweep) {
309
+ const sweepInterval = setInterval(() => {
310
+ const now = Date.now();
311
+ for (const [id, tenant] of tenants) {
312
+ if (id === 'default')
313
+ continue;
314
+ if (now - tenant.lastActivityAt > TENANT_IDLE_TIMEOUT_MS) {
315
+ onSweep(id);
316
+ disposeTenant(id);
317
+ }
318
+ }
319
+ }, TENANT_SWEEP_INTERVAL_MS);
320
+ sweepInterval.unref();
321
+ return sweepInterval;
322
+ }
323
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep };
@@ -0,0 +1,88 @@
1
+ export interface SubmitPayload {
2
+ __interrupted: boolean;
3
+ __disposed?: boolean;
4
+ [field: string]: unknown;
5
+ }
6
+ export type ServerMessage<TSchema = unknown, TValues = unknown> = {
7
+ type: 'init' | 'reinit';
8
+ schema: TSchema;
9
+ state: TValues;
10
+ } | {
11
+ type: 'update';
12
+ field: string;
13
+ value: unknown;
14
+ } | CallMessage;
15
+ export interface SetMessage {
16
+ type: 'set';
17
+ field: string;
18
+ value: unknown;
19
+ }
20
+ export interface SubmitMessage {
21
+ type: 'submit';
22
+ }
23
+ export interface InterruptMessage {
24
+ type: 'interrupt';
25
+ }
26
+ export interface ToolParamSpec {
27
+ type: 'string' | 'number' | 'boolean';
28
+ description?: string;
29
+ optional?: boolean;
30
+ }
31
+ /**
32
+ * The wire form of a manifest entry, as sent to the server in a
33
+ * RegisterToolsMessage. No function reference here — only JSON-serializable
34
+ * fields. The page keeps the actual function (see PageToolDef in
35
+ * client-bridge.ts) and dispatches on it locally when the server sends a
36
+ * CallMessage back by `name`.
37
+ */
38
+ export interface ToolManifestEntry {
39
+ name: string;
40
+ description: string;
41
+ params: Record<string, ToolParamSpec>;
42
+ example?: Record<string, unknown>;
43
+ }
44
+ export interface RegisterToolsMessage {
45
+ type: 'register_tools';
46
+ tools: ToolManifestEntry[];
47
+ /**
48
+ * Optional page-authored context shared across all tools in this manifest:
49
+ * what kind of page/app this is, cross-tool sequencing rules ("call X
50
+ * before Y"), and any domain concepts an agent needs before calling
51
+ * individual tools blindly. Distinct from each tool's own `description` -
52
+ * this is manifest-level, told once, not repeated per tool. Surfaced to
53
+ * MCP clients via the `describe_tools` tool that createManifestToolRegistry
54
+ * auto-registers (see manifest-tools.ts) since it arrives after the
55
+ * McpServer is already constructed and can't be baked into static server
56
+ * `instructions`.
57
+ */
58
+ summary?: string;
59
+ /**
60
+ * Optional page-authored app identity (e.g. document.title or
61
+ * window.__mcpAppName). Used to disambiguate tool names and connections
62
+ * when a tenant has more than one live WS connection. Sanitized
63
+ * server-side into a slug; the raw value is only used as a display label.
64
+ */
65
+ appLabel?: string;
66
+ }
67
+ export interface CallMessage {
68
+ type: 'call';
69
+ id: string;
70
+ name: string;
71
+ args: unknown;
72
+ }
73
+ export interface CallResultMessage {
74
+ type: 'call_result';
75
+ id: string;
76
+ result?: unknown;
77
+ error?: string;
78
+ }
79
+ /**
80
+ * Renames an already-registered connection's display label (and therefore
81
+ * its tool-name prefix once re-slugged) without resending its whole
82
+ * manifest. Fire-and-forget, like RegisterToolsMessage - no ack.
83
+ */
84
+ export interface RenameConnectionMessage {
85
+ type: 'rename_connection';
86
+ appLabel: string;
87
+ }
88
+ export type ClientMessage = SetMessage | SubmitMessage | InterruptMessage | RegisterToolsMessage | CallResultMessage | RenameConnectionMessage;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/ws.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { Server } from 'node:http';
2
+ import { type WebSocket } from 'ws';
3
+ export declare function attachWebSocketServer<TSchema, TValues>(httpServer: Server, port: number, initialSchema: TSchema, initialValues: TValues): import("ws").Server<typeof WebSocket, typeof import("http").IncomingMessage>;
package/dist/ws.js ADDED
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { WebSocketServer } from 'ws';
3
+ import { getOrCreateTenant, tenants } from './tenant.js';
4
+ /**
5
+ * Ping interval for the liveness check below. Half-open sockets (client
6
+ * process killed/suspended without a clean TCP close — the common case for
7
+ * a backgrounded browser tab or a harness that drops its child process)
8
+ * otherwise sit in `wsClients`/`connections` indefinitely: no 'close' event
9
+ * ever fires, so removeConnection() never runs and Tenant.call() keeps
10
+ * "succeeding" at sending into a socket that will never respond, running
11
+ * out the clock on its own 10s timeout instead of failing fast.
12
+ */
13
+ const HEARTBEAT_INTERVAL_MS = 15_000;
14
+ const heartbeatState = new WeakMap();
15
+ export function attachWebSocketServer(httpServer, port, initialSchema, initialValues) {
16
+ const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
17
+ const heartbeat = setInterval(() => {
18
+ for (const ws of wss.clients) {
19
+ const state = heartbeatState.get(ws);
20
+ if (state && !state.isAlive) {
21
+ console.error('[ws] heartbeat missed, terminating dead connection');
22
+ ws.terminate();
23
+ continue;
24
+ }
25
+ if (state)
26
+ state.isAlive = false;
27
+ ws.ping();
28
+ }
29
+ }, HEARTBEAT_INTERVAL_MS);
30
+ heartbeat.unref();
31
+ wss.on('close', () => clearInterval(heartbeat));
32
+ wss.on('connection', (ws, req) => {
33
+ const wsUrl = new URL(req.url ?? '/', `http://localhost:${port}`);
34
+ const requestedTenantId = wsUrl.searchParams.get('tenant');
35
+ if (requestedTenantId && !tenants.has(requestedTenantId)) {
36
+ // An explicit tenant was requested but no longer exists (for example
37
+ // because its MCP session was disposed). Reject instead of silently
38
+ // falling back to the shared default tenant.
39
+ console.error(`[ws] rejected connection: unknown/expired tenant "${requestedTenantId}"`);
40
+ ws.close(4404, 'Unknown or expired tenant');
41
+ return;
42
+ }
43
+ const tenantId = requestedTenantId || 'default';
44
+ const t = getOrCreateTenant(tenantId, initialSchema, initialValues);
45
+ const connectionId = randomUUID();
46
+ t.registerConnection(connectionId, ws);
47
+ console.error(`[ws] connection opened: tenant=${tenantId} connection=${connectionId} (${t.connections.size} connection(s) on tenant)`);
48
+ heartbeatState.set(ws, { isAlive: true });
49
+ ws.on('pong', () => {
50
+ const state = heartbeatState.get(ws);
51
+ if (state)
52
+ state.isAlive = true;
53
+ });
54
+ ws.send(JSON.stringify({ type: 'init', schema: t.schema, state: t.store.snapshot() }));
55
+ ws.on('message', (raw) => {
56
+ t.touch();
57
+ let msg;
58
+ try {
59
+ msg = JSON.parse(raw.toString());
60
+ }
61
+ catch {
62
+ return;
63
+ }
64
+ if (msg.type === 'set' && t.store.has(msg.field)) {
65
+ t.store.set(msg.field, msg.value);
66
+ }
67
+ if (msg.type === 'submit') {
68
+ t.submitBus.emit('submit', { __interrupted: false, ...t.store.snapshot() });
69
+ }
70
+ if (msg.type === 'interrupt') {
71
+ t.submitBus.emit('submit', { __interrupted: true, ...t.store.snapshot() });
72
+ }
73
+ if (msg.type === 'register_tools') {
74
+ t.updateConnectionManifest(connectionId, msg.tools, msg.summary, msg.appLabel);
75
+ }
76
+ if (msg.type === 'rename_connection') {
77
+ t.renameConnection(connectionId, msg.appLabel);
78
+ }
79
+ if (msg.type === 'call_result') {
80
+ if (msg.error)
81
+ t.rejectCall(msg.id, msg.error);
82
+ else
83
+ t.resolveCall(msg.id, msg.result);
84
+ }
85
+ });
86
+ ws.on('close', (code, reason) => {
87
+ console.error(`[ws] connection closed: tenant=${tenantId} connection=${connectionId} code=${code} reason=${reason.toString() || '(none)'}`);
88
+ t.removeConnection(connectionId);
89
+ });
90
+ ws.on('error', (err) => {
91
+ console.error(`[ws] connection error: tenant=${tenantId} connection=${connectionId}: ${err.message}`);
92
+ });
93
+ });
94
+ return wss;
95
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "mcp-tenant-lib",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Generic tenant/session bookkeeping + MCP wiring, reusable across projects.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/anatolipr/avo-mcp-tools.git",
9
+ "directory": "packages/mcp-tenant-lib"
10
+ },
11
+ "license": "MIT",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./client": {
20
+ "types": "./dist/client-bridge.d.ts",
21
+ "default": "./dist/client-bridge.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.build.json",
33
+ "prepublishOnly": "npm run build",
34
+ "test": "node --import tsx --test test/**/*.test.ts"
35
+ },
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.12.0",
38
+ "ws": "^8.18.0",
39
+ "zod": "^3.23.8"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^24.0.0",
43
+ "@types/ws": "^8.5.0",
44
+ "tsx": "^4.19.0",
45
+ "typescript": "^5.7.0"
46
+ }
47
+ }