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 +21 -0
- package/README.md +16 -0
- package/dist/client-bridge.d.ts +57 -0
- package/dist/client-bridge.js +91 -0
- package/dist/http.d.ts +32 -0
- package/dist/http.js +160 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/manifest-tools.d.ts +21 -0
- package/dist/manifest-tools.js +162 -0
- package/dist/mcp.d.ts +8 -0
- package/dist/mcp.js +7 -0
- package/dist/tenant.d.ts +122 -0
- package/dist/tenant.js +323 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +1 -0
- package/dist/ws.d.ts +3 -0
- package/dist/ws.js +95 -0
- package/package.json +47 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/tenant.d.ts
ADDED
|
@@ -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 };
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|