@mcp-fe/mcp-worker 0.0.1

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/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # mcp-worker
2
+
3
+ This library was generated with [Nx](https://nx.dev).
4
+
5
+ ## Building
6
+
7
+ Run `nx build mcp-worker` to build the library.
8
+
9
+ ## Running unit tests
10
+
11
+ Run `nx test mcp-worker` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -0,0 +1,22 @@
1
+ import baseConfig from '../../eslint.config.mjs';
2
+
3
+ export default [
4
+ ...baseConfig,
5
+ {
6
+ files: ['**/*.json'],
7
+ rules: {
8
+ '@nx/dependency-checks': [
9
+ 'error',
10
+ {
11
+ ignoredFiles: [
12
+ '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
13
+ '{projectRoot}/vite.config.{js,ts,mjs,mts}',
14
+ ],
15
+ },
16
+ ],
17
+ },
18
+ languageOptions: {
19
+ parser: await import('jsonc-eslint-parser'),
20
+ },
21
+ },
22
+ ];
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@mcp-fe/mcp-worker",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "types": "./index.d.ts",
8
+ "dependencies": {},
9
+ "publishConfig": {
10
+ "access": "public"
11
+ }
12
+ }
package/project.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "mcp-worker",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "libs/mcp-worker/src",
5
+ "projectType": "library",
6
+ "tags": [],
7
+ "// targets": "to see all targets run: nx show project mcp-worker --web",
8
+ "targets": {}
9
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './lib/worker-client';
2
+ export { queryEvents, type UserEvent } from './lib/database';
@@ -0,0 +1,122 @@
1
+ /**
2
+ * IndexedDB operations for storing and querying user events
3
+ */
4
+
5
+ const DB_NAME = 'user-activity-db'
6
+ const DB_VERSION = 1
7
+ const STORE_NAME = 'user-events'
8
+
9
+ export interface UserEvent {
10
+ id: string
11
+ type: 'navigation' | 'click' | 'input' | 'custom'
12
+ timestamp: number
13
+ path?: string
14
+ from?: string
15
+ to?: string
16
+ element?: string
17
+ elementId?: string
18
+ elementClass?: string
19
+ elementText?: string
20
+ metadata?: Record<string, unknown>
21
+ }
22
+
23
+ export interface EventFilters {
24
+ type?: string
25
+ startTime?: number
26
+ endTime?: number
27
+ path?: string
28
+ limit?: number
29
+ }
30
+
31
+ // Initialize IndexedDB
32
+ export async function initDB(): Promise<IDBDatabase> {
33
+ return new Promise((resolve, reject) => {
34
+ const request = indexedDB.open(DB_NAME, DB_VERSION)
35
+
36
+ request.onerror = () => reject(request.error)
37
+ request.onsuccess = () => resolve(request.result)
38
+
39
+ request.onupgradeneeded = (event) => {
40
+ const db = (event.target as IDBOpenDBRequest).result
41
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
42
+ const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
43
+ store.createIndex('timestamp', 'timestamp', { unique: false })
44
+ store.createIndex('type', 'type', { unique: false })
45
+ store.createIndex('path', 'path', { unique: false })
46
+ }
47
+ }
48
+ })
49
+ }
50
+
51
+ // Store event in IndexedDB
52
+ export async function storeEvent(event: Omit<UserEvent, 'id'>): Promise<void> {
53
+ const db = await initDB()
54
+ const transaction = db.transaction([STORE_NAME], 'readwrite')
55
+ const store = transaction.objectStore(STORE_NAME)
56
+
57
+ const eventWithId: UserEvent = {
58
+ ...event,
59
+ id: `${event.timestamp}-${Math.random().toString(36).substr(2, 9)}`,
60
+ }
61
+
62
+ await new Promise<void>((resolve, reject) => {
63
+ const request = store.add(eventWithId)
64
+ request.onsuccess = () => resolve()
65
+ request.onerror = () => reject(request.error)
66
+ })
67
+
68
+ // Clean up old events (keep last 1000 events)
69
+ const countRequest = store.count()
70
+ countRequest.onsuccess = () => {
71
+ if (countRequest.result > 1000) {
72
+ const index = store.index('timestamp')
73
+ const getAllRequest = index.getAll()
74
+ getAllRequest.onsuccess = () => {
75
+ const events = getAllRequest.result as UserEvent[]
76
+ events.sort((a, b) => a.timestamp - b.timestamp)
77
+ const toDelete = events.slice(0, events.length - 1000)
78
+ toDelete.forEach((event) => store.delete(event.id))
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ // Query events from IndexedDB
85
+ export async function queryEvents(filters?: EventFilters): Promise<UserEvent[]> {
86
+ const db = await initDB()
87
+ const transaction = db.transaction([STORE_NAME], 'readonly')
88
+ const store = transaction.objectStore(STORE_NAME)
89
+ const index = store.index('timestamp')
90
+
91
+ return new Promise((resolve, reject) => {
92
+ const request = index.getAll()
93
+ request.onsuccess = () => {
94
+ let events = request.result as UserEvent[]
95
+
96
+ // Apply filters
97
+ if (filters?.type) {
98
+ events = events.filter((e) => e.type === filters.type)
99
+ }
100
+ if (filters?.startTime) {
101
+ events = events.filter((e) => e.timestamp >= filters.startTime!)
102
+ }
103
+ if (filters?.endTime) {
104
+ events = events.filter((e) => e.timestamp <= filters.endTime!)
105
+ }
106
+ if (filters?.path) {
107
+ events = events.filter((e) => e.path?.includes(filters.path!))
108
+ }
109
+
110
+ // Sort by timestamp descending (newest first)
111
+ events.sort((a, b) => b.timestamp - a.timestamp)
112
+
113
+ // Apply limit
114
+ if (filters?.limit) {
115
+ events = events.slice(0, filters.limit)
116
+ }
117
+
118
+ resolve(events)
119
+ }
120
+ request.onerror = () => reject(request.error)
121
+ })
122
+ }
@@ -0,0 +1,199 @@
1
+ /* eslint-disable no-restricted-globals */
2
+ /**
3
+ * MCP Controller
4
+ *
5
+ * Encapsulates the shared WebSocket / MCP server / storage logic used by
6
+ * both SharedWorker and ServiceWorker implementations.
7
+ */
8
+
9
+ import { storeEvent, queryEvents, UserEvent } from './database';
10
+ import { mcpServer } from './mcp-server';
11
+ import { WebSocketTransport } from './websocket-transport';
12
+
13
+ const MAX_RECONNECT_DELAY = 30000;
14
+ const INITIAL_RECONNECT_DELAY = 1000;
15
+
16
+ export type BroadcastFn = (message: unknown) => void;
17
+
18
+ export class MCPController {
19
+ private socket: WebSocket | null = null;
20
+ private transport: WebSocketTransport | null = null;
21
+ private reconnectAttempts = 0;
22
+ private authToken: string | null = null;
23
+ private keepAliveInterval: ReturnType<typeof setInterval> | null = null;
24
+ private requireAuth: boolean;
25
+
26
+ constructor(private backendUrl: string, private broadcastFn: BroadcastFn, requireAuth = true) {
27
+ this.requireAuth = requireAuth;
28
+ }
29
+
30
+ private startKeepAlive(): void {
31
+ if (this.keepAliveInterval) {
32
+ clearInterval(this.keepAliveInterval);
33
+ }
34
+
35
+ this.keepAliveInterval = setInterval(() => {
36
+ if (this.socket?.readyState === WebSocket.OPEN) {
37
+ try {
38
+ this.socket.send(JSON.stringify({ type: 'ping' }));
39
+ } catch {
40
+ // ignore send errors
41
+ }
42
+ }
43
+ }, 20000);
44
+ }
45
+
46
+ private stopKeepAlive(): void {
47
+ if (this.keepAliveInterval) {
48
+ clearInterval(this.keepAliveInterval);
49
+ this.keepAliveInterval = null;
50
+ }
51
+ }
52
+
53
+ public async connectWebSocket(): Promise<void> {
54
+ // If we require auth and don't have a token yet, do not attempt connection
55
+ if (this.requireAuth && !this.authToken) {
56
+ console.log('[MCPController] Skipping WebSocket connect: auth token not set and requireAuth=true');
57
+ return;
58
+ }
59
+
60
+ if (this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) {
61
+ return;
62
+ }
63
+
64
+ // Clean up existing transport and socket
65
+ if (this.transport) {
66
+ try {
67
+ await mcpServer.close();
68
+ } catch (error) {
69
+ console.error('[MCPController] Error closing MCP server:', error);
70
+ }
71
+ this.transport = null;
72
+ }
73
+
74
+ if (this.socket) {
75
+ this.socket.onopen = null;
76
+ this.socket.onclose = null;
77
+ this.socket.onerror = null;
78
+ if (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) {
79
+ this.socket.close();
80
+ }
81
+ this.socket = null;
82
+ }
83
+
84
+ return new Promise((resolve) => {
85
+ const url = this.authToken ? `${this.backendUrl}?token=${this.authToken}` : this.backendUrl;
86
+ this.socket = new WebSocket(url);
87
+
88
+ this.socket.onopen = async () => {
89
+ console.log('[MCPController] Connected to backend MCP server');
90
+ this.reconnectAttempts = 0;
91
+
92
+ try {
93
+ if (this.socket) {
94
+ this.transport = new WebSocketTransport(this.socket);
95
+ // start transport if available
96
+ if (typeof this.transport.start === 'function') {
97
+ try {
98
+ await this.transport.start();
99
+ } catch {
100
+ // some transport implementations may not require start
101
+ // ignore
102
+ }
103
+ }
104
+
105
+ await mcpServer.connect(this.transport);
106
+ console.log('[MCPController] MCP Server connected to WebSocket transport');
107
+
108
+ this.startKeepAlive();
109
+ this.broadcastFn({ type: 'CONNECTION_STATUS', connected: true });
110
+ resolve();
111
+ }
112
+ } catch (error) {
113
+ console.error('[MCPController] Error setting up MCP server:', error);
114
+ this.broadcastFn({ type: 'CONNECTION_STATUS', connected: false });
115
+ if (this.socket) {
116
+ this.socket.close();
117
+ }
118
+ resolve();
119
+ }
120
+ };
121
+
122
+ this.socket.onclose = async (event: CloseEvent) => {
123
+ console.log('[MCPController] Disconnected from backend MCP server', event?.code, event?.reason);
124
+ this.broadcastFn({ type: 'CONNECTION_STATUS', connected: false });
125
+
126
+ if (this.transport) {
127
+ try {
128
+ await mcpServer.close();
129
+ } catch (error) {
130
+ console.error('[MCPController] Error closing MCP server:', error);
131
+ }
132
+ this.transport = null;
133
+ }
134
+
135
+ this.socket = null;
136
+ this.stopKeepAlive();
137
+
138
+ if (event?.code !== 1000) {
139
+ const delay = Math.min(
140
+ INITIAL_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts),
141
+ MAX_RECONNECT_DELAY,
142
+ );
143
+ this.reconnectAttempts++;
144
+ console.log(`[MCPController] Retrying in ${delay}ms...`);
145
+ setTimeout(() => this.connectWebSocket(), delay);
146
+ }
147
+
148
+ resolve();
149
+ };
150
+
151
+ this.socket.onerror = (event) => {
152
+ console.error('[MCPController] WebSocket error:', event);
153
+ this.broadcastFn({ type: 'CONNECTION_STATUS', connected: false });
154
+ };
155
+ });
156
+ }
157
+
158
+ public setAuthToken(token: string | null): void {
159
+ const tokenChanged = this.authToken !== token;
160
+ this.authToken = token;
161
+
162
+ if (tokenChanged) {
163
+ console.log('[MCPController] Auth token changed, reconnecting WebSocket...');
164
+ if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
165
+ // Close with normal closure to prevent auto-reconnect in some environments
166
+ this.socket.close(1000, 'Reconnecting with new auth token');
167
+ }
168
+
169
+ // small delay before reconnecting
170
+ setTimeout(() => {
171
+ this.connectWebSocket().catch((error) => console.error('[MCPController] Failed to reconnect with new token:', error));
172
+ }, 100);
173
+ }
174
+ }
175
+
176
+ public async handleStoreEvent(userEvent: UserEvent): Promise<void> {
177
+ await storeEvent(userEvent);
178
+ }
179
+
180
+ public async handleGetEvents(): Promise<ReturnType<typeof queryEvents>> {
181
+ return queryEvents({ limit: 50 });
182
+ }
183
+
184
+ public getConnectionStatus(): boolean {
185
+ return this.socket?.readyState === WebSocket.OPEN;
186
+ }
187
+
188
+ public dispose(): void {
189
+ this.stopKeepAlive();
190
+ if (this.socket) {
191
+ try {
192
+ this.socket.close(1000, 'Worker disposed');
193
+ } catch {
194
+ // ignore
195
+ }
196
+ this.socket = null;
197
+ }
198
+ }
199
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * MCP Server setup and request handlers
3
+ * Uses @modelcontextprotocol/sdk for type safety and validation
4
+ */
5
+
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
7
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
8
+ import { z } from 'zod'
9
+ import { queryEvents } from './database'
10
+
11
+ // Create MCP server instance for service worker
12
+ export const mcpServer = new Server(
13
+ {
14
+ name: 'mcp-worker-server',
15
+ version: '1.0.0',
16
+ },
17
+ {
18
+ capabilities: {
19
+ tools: {},
20
+ },
21
+ },
22
+ )
23
+
24
+ // Register tools list handler using SDK
25
+ mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
26
+ return {
27
+ tools: [
28
+ {
29
+ name: 'get_user_events',
30
+ description: 'Get user activity events (navigation, clicks, etc.)',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ type: {
35
+ type: 'string',
36
+ enum: ['navigation', 'click', 'input', 'custom'],
37
+ description: 'Filter by event type',
38
+ },
39
+ startTime: {
40
+ type: 'number',
41
+ description: 'Start timestamp (Unix timestamp in milliseconds)',
42
+ },
43
+ endTime: {
44
+ type: 'number',
45
+ description: 'End timestamp (Unix timestamp in milliseconds)',
46
+ },
47
+ path: {
48
+ type: 'string',
49
+ description: 'Filter by path/URL',
50
+ },
51
+ limit: {
52
+ type: 'number',
53
+ description: 'Maximum number of events to return',
54
+ default: 100,
55
+ },
56
+ },
57
+ },
58
+ },
59
+ {
60
+ name: 'get_navigation_history',
61
+ description: 'Get user navigation history',
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: {
65
+ limit: {
66
+ type: 'number',
67
+ description: 'Maximum number of navigation events to return',
68
+ default: 50,
69
+ },
70
+ },
71
+ },
72
+ },
73
+ {
74
+ name: 'get_click_events',
75
+ description: 'Get user click events',
76
+ inputSchema: {
77
+ type: 'object',
78
+ properties: {
79
+ element: {
80
+ type: 'string',
81
+ description: 'Filter by element selector or text',
82
+ },
83
+ limit: {
84
+ type: 'number',
85
+ description: 'Maximum number of click events to return',
86
+ default: 100,
87
+ },
88
+ },
89
+ },
90
+ },
91
+ ],
92
+ };
93
+ })
94
+
95
+ // Register tool call handler using SDK
96
+ mcpServer.setRequestHandler(
97
+ CallToolRequestSchema,
98
+ async (request: { params: { name: string; arguments?: unknown } }) => {
99
+ const { name, arguments: args } = request.params;
100
+
101
+ switch (name) {
102
+ case 'get_user_events': {
103
+ const schema = z.object({
104
+ type: z.enum(['navigation', 'click', 'input', 'custom']).optional(),
105
+ startTime: z.number().optional(),
106
+ endTime: z.number().optional(),
107
+ path: z.string().optional(),
108
+ limit: z.number().optional().default(100),
109
+ });
110
+
111
+ const validatedArgs = schema.parse(args || {});
112
+ const events = await queryEvents(validatedArgs);
113
+
114
+ return {
115
+ content: [
116
+ {
117
+ type: 'text',
118
+ text: JSON.stringify({ events }, null, 2),
119
+ },
120
+ ],
121
+ };
122
+ }
123
+
124
+ case 'get_navigation_history': {
125
+ const schema = z.object({
126
+ limit: z.number().optional().default(50),
127
+ });
128
+
129
+ const validatedArgs = schema.parse(args || {});
130
+ const events = await queryEvents({
131
+ type: 'navigation',
132
+ limit: validatedArgs.limit,
133
+ });
134
+
135
+ return {
136
+ content: [
137
+ {
138
+ type: 'text',
139
+ text: JSON.stringify(
140
+ {
141
+ navigationHistory: events.map((e) => ({
142
+ from: e.from,
143
+ to: e.to,
144
+ path: e.path,
145
+ timestamp: e.timestamp,
146
+ })),
147
+ },
148
+ null,
149
+ 2,
150
+ ),
151
+ },
152
+ ],
153
+ };
154
+ }
155
+
156
+ case 'get_click_events': {
157
+ const schema = z.object({
158
+ element: z.string().optional(),
159
+ limit: z.number().optional().default(100),
160
+ });
161
+
162
+ const validatedArgs = schema.parse(args || {});
163
+ const events = await queryEvents({
164
+ type: 'click',
165
+ limit: validatedArgs.limit,
166
+ });
167
+
168
+ let filteredEvents = events;
169
+ if (validatedArgs.element) {
170
+ const elementFilter = validatedArgs.element.toLowerCase();
171
+ filteredEvents = events.filter(
172
+ (e) =>
173
+ e.element?.toLowerCase().includes(elementFilter) ||
174
+ e.elementText?.toLowerCase().includes(elementFilter) ||
175
+ e.elementId?.toLowerCase().includes(elementFilter) ||
176
+ e.elementClass?.toLowerCase().includes(elementFilter),
177
+ );
178
+ }
179
+
180
+ return {
181
+ content: [
182
+ {
183
+ type: 'text',
184
+ text: JSON.stringify(
185
+ {
186
+ clickEvents: filteredEvents.map((e) => ({
187
+ element: e.element,
188
+ elementId: e.elementId,
189
+ elementClass: e.elementClass,
190
+ elementText: e.elementText,
191
+ path: e.path,
192
+ timestamp: e.timestamp,
193
+ metadata: e.metadata,
194
+ })),
195
+ },
196
+ null,
197
+ 2,
198
+ ),
199
+ },
200
+ ],
201
+ };
202
+ }
203
+
204
+ default:
205
+ throw new Error(`Unknown tool: ${name}`);
206
+ }
207
+ },
208
+ );
209
+
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Service Worker MCP Server
3
+ *
4
+ * This service worker acts as an MCP (Model Context Protocol) server,
5
+ * storing user events and exposing them via MCP protocol endpoints.
6
+ */
7
+
8
+ declare const self: ServiceWorkerGlobalScope;
9
+
10
+ import { UserEvent } from './database';
11
+ import { MCPController } from './mcp-controller';
12
+
13
+ const BACKEND_WS_URL = 'ws://localhost:3001';
14
+
15
+ // Broadcast to all clients
16
+ const controller = new MCPController(BACKEND_WS_URL, (message: unknown) => {
17
+ self.clients.matchAll().then((clients) => {
18
+ clients.forEach((client) => {
19
+ try {
20
+ client.postMessage(message);
21
+ } catch (e) {
22
+ // ignore
23
+ console.error('[ServiceWorker] Failed to post message to client:', e);
24
+ }
25
+ });
26
+ }).catch((err) => {
27
+ console.error('[ServiceWorker] Failed to match clients for broadcast:', err);
28
+ });
29
+ });
30
+
31
+ // Handle messages from the main thread
32
+ self.addEventListener('message', async (event: ExtendableMessageEvent) => {
33
+ if (!event.data) return;
34
+
35
+ if (event.data.type === 'SET_AUTH_TOKEN') {
36
+ controller.setAuthToken(event.data.token);
37
+ return;
38
+ }
39
+
40
+ if (event.data.type === 'STORE_EVENT') {
41
+ event.waitUntil((async () => {
42
+ try {
43
+ const userEvent = event.data.event as UserEvent;
44
+ await controller.handleStoreEvent(userEvent);
45
+
46
+ if (event.ports && event.ports[0]) {
47
+ event.ports[0].postMessage({ success: true });
48
+ }
49
+ } catch (error) {
50
+ if (event.ports && event.ports[0]) {
51
+ event.ports[0].postMessage({ success: false, error: error instanceof Error ? error.message : 'Unknown error' });
52
+ }
53
+ }
54
+ })());
55
+ } else if (event.data.type === 'GET_EVENTS') {
56
+ event.waitUntil((async () => {
57
+ try {
58
+ const events = await controller.handleGetEvents();
59
+ if (event.ports && event.ports[0]) {
60
+ event.ports[0].postMessage({ success: true, events });
61
+ }
62
+ } catch (error) {
63
+ if (event.ports && event.ports[0]) {
64
+ event.ports[0].postMessage({ success: false, error: error instanceof Error ? error.message : 'Unknown error' });
65
+ }
66
+ }
67
+ })());
68
+ } else if (event.data.type === 'GET_CONNECTION_STATUS') {
69
+ if (event.ports && event.ports[0]) {
70
+ event.ports[0].postMessage({ success: true, connected: controller.getConnectionStatus() });
71
+ }
72
+ }
73
+ });
74
+
75
+ // Install and activate
76
+ self.addEventListener('install', (event: ExtendableEvent) => {
77
+ event.waitUntil(self.skipWaiting());
78
+ });
79
+
80
+ self.addEventListener('activate', (event: ExtendableEvent) => {
81
+ // Do not automatically start the WebSocket connection here.
82
+ // If a client intends to use the service worker it will send messages
83
+ // (e.g. SET_AUTH_TOKEN) and the controller will connect on demand.
84
+ event.waitUntil(
85
+ Promise.resolve(self.clients.claim())
86
+ );
87
+ });
@@ -0,0 +1,109 @@
1
+ /* eslint-disable no-restricted-globals */
2
+ /**
3
+ * SharedWorker MCP Server
4
+ *
5
+ * This SharedWorker acts as an MCP (Model Context Protocol) server,
6
+ * storing user events and exposing them via MCP protocol endpoints.
7
+ * Falls back to ServiceWorker if SharedWorker is not supported.
8
+ */
9
+
10
+ declare const self: SharedWorkerGlobalScope;
11
+
12
+ import { UserEvent } from './database';
13
+ import { MCPController } from './mcp-controller';
14
+
15
+ const BACKEND_WS_URL = 'ws://localhost:3001';
16
+
17
+ // Track all connected ports
18
+ const connectedPorts: MessagePort[] = [];
19
+
20
+ // Create controller with a broadcast function that posts to all connected ports
21
+ const controller = new MCPController(BACKEND_WS_URL, (message: unknown) => {
22
+ connectedPorts.forEach((port) => {
23
+ try {
24
+ port.postMessage(message);
25
+ } catch (error) {
26
+ const idx = connectedPorts.indexOf(port);
27
+ if (idx > -1) connectedPorts.splice(idx, 1);
28
+ }
29
+ });
30
+ });
31
+
32
+ // Handle new connections
33
+ self.onconnect = (event: MessageEvent) => {
34
+ const port = event.ports[0];
35
+ connectedPorts.push(port);
36
+
37
+ // Send initial connection status
38
+ try {
39
+ port.postMessage({ type: 'CONNECTION_STATUS', connected: controller.getConnectionStatus() });
40
+ } catch (err) {
41
+ console.error('[SharedWorker] Failed to post initial status to port:', err);
42
+ }
43
+
44
+ port.onmessage = async (ev: MessageEvent) => {
45
+ if (!ev.data) return;
46
+ const messageData = ev.data;
47
+
48
+ if (messageData.type === 'SET_AUTH_TOKEN') {
49
+ const newToken = (messageData as any).token as string | null;
50
+ controller.setAuthToken(newToken);
51
+ return;
52
+ }
53
+
54
+ if (messageData.type === 'STORE_EVENT') {
55
+ try {
56
+ const userEvent = messageData.event as UserEvent;
57
+ await controller.handleStoreEvent(userEvent);
58
+ try {
59
+ port.postMessage({ success: true });
60
+ } catch (error) {
61
+ console.error('[SharedWorker] Failed to post success to port:', error);
62
+ }
63
+ } catch (error) {
64
+ try {
65
+ port.postMessage({ success: false, error: error instanceof Error ? error.message : 'Unknown error' });
66
+ } catch (e) {
67
+ console.error('[SharedWorker] Failed to post failure to port:', e);
68
+ }
69
+ }
70
+
71
+ return;
72
+ }
73
+
74
+ if (messageData.type === 'GET_EVENTS') {
75
+ try {
76
+ const events = await controller.handleGetEvents();
77
+ try {
78
+ port.postMessage({ success: true, events });
79
+ } catch (error) {
80
+ console.error('[SharedWorker] Failed to post events to port:', error);
81
+ }
82
+ } catch (error) {
83
+ try {
84
+ port.postMessage({ success: false, error: error instanceof Error ? error.message : 'Unknown error' });
85
+ } catch (e) {
86
+ console.error('[SharedWorker] Failed to post failure to port:', e);
87
+ }
88
+ }
89
+ return;
90
+ }
91
+
92
+ if (messageData.type === 'GET_CONNECTION_STATUS') {
93
+ try {
94
+ port.postMessage({ success: true, connected: controller.getConnectionStatus() });
95
+ } catch (error) {
96
+ console.error('[SharedWorker] Failed to post connection status to port:', error);
97
+ }
98
+ return;
99
+ }
100
+ };
101
+
102
+ // Handle port disconnection
103
+ port.onmessageerror = () => {
104
+ const index = connectedPorts.indexOf(port);
105
+ if (index > -1) {
106
+ connectedPorts.splice(index, 1);
107
+ }
108
+ };
109
+ };
@@ -0,0 +1,43 @@
1
+ import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
+ import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
3
+
4
+ /**
5
+ * Custom MCP Transport for WebSocket in Service Worker
6
+ */
7
+ export class WebSocketTransport implements Transport {
8
+
9
+ onclose?: () => void;
10
+ onerror?: (error: Error) => void;
11
+ onmessage?: (message: JSONRPCMessage) => void;
12
+
13
+ constructor(private ws: WebSocket) {}
14
+
15
+ async start(): Promise<void> {
16
+ this.ws.addEventListener('message', (event) => {
17
+ try {
18
+ const message = JSON.parse(event.data);
19
+ this.onmessage?.(message);
20
+ } catch (error) {
21
+ this.onerror?.(
22
+ error instanceof Error ? error : new Error(String(error)),
23
+ );
24
+ }
25
+ });
26
+
27
+ this.ws.addEventListener('close', () => {
28
+ this.onclose?.();
29
+ });
30
+
31
+ this.ws.addEventListener('error', (event) => {
32
+ this.onerror?.(new Error('WebSocket error'));
33
+ });
34
+ }
35
+
36
+ async send(message: JSONRPCMessage): Promise<void> {
37
+ this.ws.send(JSON.stringify(message));
38
+ }
39
+
40
+ async close(): Promise<void> {
41
+ this.ws.close();
42
+ }
43
+ }
@@ -0,0 +1,369 @@
1
+ type WorkerType = 'shared' | 'service';
2
+
3
+ export class WorkerClient {
4
+ private serviceWorkerRegistration: ServiceWorkerRegistration | null = null;
5
+ private sharedWorker: SharedWorker | null = null;
6
+ private sharedWorkerPort: MessagePort | null = null;
7
+ private workerType: WorkerType | null = null;
8
+ private pendingAuthToken: string | null = null;
9
+ // connection status subscribers
10
+ private connectionStatusCallbacks: Set<(connected: boolean) => void> = new Set();
11
+ private serviceWorkerMessageHandler: ((ev: MessageEvent) => void) | null = null;
12
+
13
+ // Mutex/promise to prevent concurrent init runs
14
+ private initPromise: Promise<void> | null = null;
15
+
16
+ // Initialize and choose worker implementation (prefer SharedWorker)
17
+ public async init(registration?: ServiceWorkerRegistration): Promise<void> {
18
+ // If an init is already in progress, wait for it and optionally retry if caller provided a registration
19
+ if (this.initPromise) {
20
+ return this.initPromise.then(async () => {
21
+ if (registration && this.workerType !== 'service') {
22
+ // retry once with provided registration after current init finished
23
+ await this.init(registration);
24
+ }
25
+ });
26
+ }
27
+
28
+ // Start initialization and store the promise as a mutex
29
+ this.initPromise = (async () => {
30
+ try {
31
+ // If an explicit ServiceWorker registration is provided, use it
32
+ if (registration) {
33
+ this.serviceWorkerRegistration = registration;
34
+ this.workerType = 'service';
35
+ console.log('[WorkerClient] Using ServiceWorker (explicit registration)');
36
+ // send pending token if exists
37
+ if (this.pendingAuthToken) {
38
+ this.sendAuthTokenToServiceWorker(this.pendingAuthToken);
39
+ this.pendingAuthToken = null;
40
+ }
41
+ return;
42
+ }
43
+
44
+ // Try SharedWorker first
45
+ if (typeof SharedWorker !== 'undefined') {
46
+ try {
47
+ this.sharedWorker = new SharedWorker('/shared-worker.js', { type: 'module' });
48
+ this.sharedWorkerPort = this.sharedWorker.port;
49
+ this.sharedWorkerPort.start();
50
+
51
+ if (this.pendingAuthToken && this.sharedWorkerPort) {
52
+ try {
53
+ this.sharedWorkerPort.postMessage({ type: 'SET_AUTH_TOKEN', token: this.pendingAuthToken });
54
+ } catch (err) {
55
+ console.warn('[WorkerClient] Immediate postMessage to SharedWorker failed (will retry after init):', err);
56
+ }
57
+ }
58
+
59
+ this.sharedWorker.onerror = (event: ErrorEvent) => {
60
+ console.error('[WorkerClient] SharedWorker error:', event.message || event.error || event);
61
+ if (this.workerType !== 'shared') {
62
+ this.initServiceWorkerFallback().catch((err) => {
63
+ console.error('[WorkerClient] Failed to initialize ServiceWorker fallback:', err);
64
+ });
65
+ }
66
+ };
67
+
68
+ await new Promise<void>((resolve, reject) => {
69
+ let resolved = false;
70
+ const timeout = setTimeout(() => {
71
+ if (!resolved) {
72
+ const p = this.sharedWorkerPort;
73
+ if (p) p.onmessage = null;
74
+ reject(new Error('SharedWorker initialization timeout'));
75
+ }
76
+ }, 2000);
77
+
78
+ const p = this.sharedWorkerPort;
79
+ if (!p) {
80
+ clearTimeout(timeout);
81
+ return reject(new Error('SharedWorker port not available'));
82
+ }
83
+
84
+ p.onmessage = (ev: MessageEvent) => {
85
+ try {
86
+ const data = ev.data;
87
+ if (data && data.type === 'CONNECTION_STATUS') {
88
+ clearTimeout(timeout);
89
+ resolved = true;
90
+ this.workerType = 'shared';
91
+ p.onmessage = null;
92
+ resolve();
93
+ }
94
+ } catch {
95
+ // ignore parse/handler errors
96
+ }
97
+ };
98
+ });
99
+
100
+ const portAfterInit = this.sharedWorkerPort;
101
+ if (this.pendingAuthToken && portAfterInit) {
102
+ try {
103
+ portAfterInit.postMessage({ type: 'SET_AUTH_TOKEN', token: this.pendingAuthToken });
104
+ this.pendingAuthToken = null;
105
+ } catch (e) {
106
+ console.error('[WorkerClient] Failed to send pending auth token to SharedWorker:', e);
107
+ }
108
+ }
109
+
110
+ if (portAfterInit) {
111
+ portAfterInit.onmessage = (ev: MessageEvent) => {
112
+ try {
113
+ const data = ev.data;
114
+ if (data && data.type === 'CONNECTION_STATUS') {
115
+ const connected = !!data.connected;
116
+ this.connectionStatusCallbacks.forEach((cb) => {
117
+ try { cb(connected); } catch (e) { /* ignore callback errors */ }
118
+ });
119
+ }
120
+ } catch {
121
+ // ignore
122
+ }
123
+ };
124
+ }
125
+
126
+ console.log('[WorkerClient] Using SharedWorker');
127
+ return;
128
+ } catch (error) {
129
+ console.warn('[WorkerClient] SharedWorker not available, falling back to ServiceWorker:', error);
130
+ }
131
+ }
132
+
133
+ // If SharedWorker isn't supported or failed, use service worker
134
+ console.log("this should not be called");
135
+ await this.initServiceWorkerFallback();
136
+
137
+ // Send pending token if any
138
+ if (this.pendingAuthToken && this.workerType === 'service') {
139
+ this.sendAuthTokenToServiceWorker(this.pendingAuthToken);
140
+ this.pendingAuthToken = null;
141
+ }
142
+ } finally {
143
+ // Clear the mutex so future init calls can proceed
144
+ this.initPromise = null;
145
+ }
146
+ })();
147
+
148
+ return this.initPromise;
149
+ }
150
+
151
+ private async initServiceWorkerFallback(): Promise<void> {
152
+ console.log("initServiceWorkerFallback called");
153
+ if ('serviceWorker' in navigator) {
154
+ try {
155
+ const existingRegistration = await navigator.serviceWorker.getRegistration();
156
+ if (existingRegistration) {
157
+ this.serviceWorkerRegistration = existingRegistration;
158
+ this.workerType = 'service';
159
+ console.log('[WorkerClient] Using existing ServiceWorker registration');
160
+ return;
161
+ }
162
+
163
+ const reg = await navigator.serviceWorker.register('/sw.js');
164
+ this.serviceWorkerRegistration = reg;
165
+ this.workerType = 'service';
166
+ console.log('[WorkerClient] Using ServiceWorker (fallback)');
167
+ if (this.serviceWorkerMessageHandler) {
168
+ navigator.serviceWorker.removeEventListener('message', this.serviceWorkerMessageHandler);
169
+ this.serviceWorkerMessageHandler = null;
170
+ }
171
+ this.serviceWorkerMessageHandler = (ev: MessageEvent) => {
172
+ try {
173
+ const data = ev.data;
174
+ if (data && data.type === 'CONNECTION_STATUS') {
175
+ const connected = !!data.connected;
176
+ this.connectionStatusCallbacks.forEach((cb) => {
177
+ try { cb(connected); } catch (e) { /* ignore callback errors */ }
178
+ });
179
+ }
180
+ } catch {
181
+ // ignore
182
+ }
183
+ };
184
+ navigator.serviceWorker.addEventListener('message', this.serviceWorkerMessageHandler);
185
+ } catch (error) {
186
+ console.error('[WorkerClient] Failed to register ServiceWorker:', error);
187
+ throw error;
188
+ }
189
+ } else {
190
+ throw new Error('Neither SharedWorker nor ServiceWorker is supported');
191
+ }
192
+ }
193
+
194
+ // Low-level request that expects a reply via MessageChannel
195
+ public async request<T = any>(type: string, payload?: Record<string, unknown>, timeoutMs = 5000): Promise<T> {
196
+ // If using shared worker
197
+ if (this.workerType === 'shared' && this.sharedWorkerPort) {
198
+ return new Promise<T>((resolve, reject) => {
199
+ const mc = new MessageChannel();
200
+ const timer = setTimeout(() => {
201
+ mc.port1.onmessage = null;
202
+ reject(new Error('Request timeout'));
203
+ }, timeoutMs);
204
+
205
+ mc.port1.onmessage = (ev: MessageEvent) => {
206
+ clearTimeout(timer);
207
+ if (ev.data && ev.data.success) {
208
+ resolve(ev.data as T);
209
+ } else if (ev.data && ev.data.success === false) {
210
+ reject(new Error(ev.data.error || 'Worker error'));
211
+ } else {
212
+ resolve(ev.data as T);
213
+ }
214
+ };
215
+
216
+ try {
217
+ const port = this.sharedWorkerPort;
218
+ if (!port) {
219
+ clearTimeout(timer);
220
+ return reject(new Error('SharedWorker port not available'));
221
+ }
222
+ port.postMessage({ type, ...(payload || {}) }, [mc.port2]);
223
+ } catch (e) {
224
+ clearTimeout(timer);
225
+ reject(e instanceof Error ? e : new Error(String(e)));
226
+ }
227
+ });
228
+ }
229
+
230
+ // If using service worker
231
+ if (this.workerType === 'service' && this.serviceWorkerRegistration) {
232
+ // Ensure service worker active
233
+ const reg = this.serviceWorkerRegistration;
234
+ if (!reg) throw new Error('Service worker registration missing');
235
+ if (!reg.active) {
236
+ await navigator.serviceWorker.ready;
237
+ if (!reg.active) {
238
+ throw new Error('Service worker not active');
239
+ }
240
+ }
241
+
242
+ return new Promise<T>((resolve, reject) => {
243
+ const mc = new MessageChannel();
244
+ const timer = setTimeout(() => {
245
+ mc.port1.onmessage = null;
246
+ reject(new Error('Request timeout'));
247
+ }, timeoutMs);
248
+
249
+ mc.port1.onmessage = (ev: MessageEvent) => {
250
+ clearTimeout(timer);
251
+ if (ev.data && ev.data.success) {
252
+ resolve(ev.data as T);
253
+ } else if (ev.data && ev.data.success === false) {
254
+ reject(new Error(ev.data.error || 'Worker error'));
255
+ } else {
256
+ resolve(ev.data as T);
257
+ }
258
+ };
259
+
260
+ try {
261
+ const active = reg.active;
262
+ if (!active) {
263
+ clearTimeout(timer);
264
+ return reject(new Error('Service worker active instance not available'));
265
+ }
266
+ active.postMessage({ type, ...(payload || {}) }, [mc.port2]);
267
+ } catch (e) {
268
+ clearTimeout(timer);
269
+ reject(e instanceof Error ? e : new Error(String(e)));
270
+ }
271
+ });
272
+ }
273
+
274
+ // No worker available
275
+ throw new Error('No worker registered');
276
+ }
277
+
278
+ // Fire-and-forget postMessage (no response expected)
279
+ public async post(type: string, payload?: Record<string, unknown>): Promise<void> {
280
+ if (this.workerType === 'shared' && this.sharedWorkerPort) {
281
+ try {
282
+ this.sharedWorkerPort.postMessage({ type, ...(payload || {}) });
283
+ } catch (e) {
284
+ console.error('[WorkerClient] Failed to post to SharedWorker:', e);
285
+ }
286
+ return;
287
+ }
288
+
289
+ if (this.workerType === 'service' && this.serviceWorkerRegistration?.active) {
290
+ try {
291
+ this.serviceWorkerRegistration.active.postMessage({ type, ...(payload || {}) });
292
+ } catch (e) {
293
+ console.error('[WorkerClient] Failed to post to ServiceWorker (active):', e);
294
+ }
295
+ return;
296
+ }
297
+
298
+ if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
299
+ try {
300
+ navigator.serviceWorker.controller.postMessage({ type, ...(payload || {}) });
301
+ } catch (e) {
302
+ console.error('[WorkerClient] Failed to post to ServiceWorker.controller:', e);
303
+ }
304
+ return;
305
+ }
306
+
307
+ // If no worker yet, queue token if SET_AUTH_TOKEN
308
+ if (type === 'SET_AUTH_TOKEN' && payload) {
309
+ const token = (payload as any)['token'];
310
+ if (typeof token === 'string') this.pendingAuthToken = token;
311
+ }
312
+ }
313
+
314
+ private sendAuthTokenToServiceWorker(token: string): void {
315
+ if (this.serviceWorkerRegistration?.active) {
316
+ try {
317
+ this.serviceWorkerRegistration.active.postMessage({ type: 'SET_AUTH_TOKEN', token });
318
+ } catch (e) {
319
+ console.error('[WorkerClient] Failed to send auth token to ServiceWorker:', e);
320
+ }
321
+ } else if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
322
+ try {
323
+ navigator.serviceWorker.controller.postMessage({ type: 'SET_AUTH_TOKEN', token });
324
+ } catch (e) {
325
+ console.error('[WorkerClient] Failed to send auth token to ServiceWorker.controller:', e);
326
+ }
327
+ } else {
328
+ // keep as pending
329
+ this.pendingAuthToken = token;
330
+ }
331
+ }
332
+
333
+ // Subscription API for consumers to listen for connection status updates
334
+ public onConnectionStatus(cb: (connected: boolean) => void): void {
335
+ this.connectionStatusCallbacks.add(cb);
336
+ }
337
+
338
+ public offConnectionStatus(cb: (connected: boolean) => void): void {
339
+ this.connectionStatusCallbacks.delete(cb);
340
+ }
341
+
342
+ public async getConnectionStatus(): Promise<boolean> {
343
+ try {
344
+ const res = await this.request('GET_CONNECTION_STATUS', undefined, 2000);
345
+ if (res && typeof res === 'object' && 'connected' in (res as any)) return !!(res as any).connected;
346
+ return !!(res as any).connected;
347
+ } catch {
348
+ return false;
349
+ }
350
+ }
351
+
352
+ public setAuthToken(token: string): void {
353
+ this.pendingAuthToken = token;
354
+ // Try to send immediately if possible
355
+ if (this.workerType === 'shared' && this.sharedWorkerPort) {
356
+ try {
357
+ this.sharedWorkerPort.postMessage({ type: 'SET_AUTH_TOKEN', token });
358
+ this.pendingAuthToken = null;
359
+ } catch (e) {
360
+ console.error('[WorkerClient] Failed to set auth token on SharedWorker:', e);
361
+ }
362
+ } else if (this.workerType === 'service') {
363
+ this.sendAuthTokenToServiceWorker(token);
364
+ this.pendingAuthToken = null;
365
+ } else {
366
+ // queued and will be sent when init finishes
367
+ }
368
+ }
369
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "forceConsistentCasingInFileNames": true,
7
+ "strict": true,
8
+ "importHelpers": true,
9
+ "noImplicitOverride": true,
10
+ "noImplicitReturns": true,
11
+ "noFallthroughCasesInSwitch": true,
12
+ "noPropertyAccessFromIndexSignature": true,
13
+ "lib": ["esnext", "dom", "webworker"]
14
+ },
15
+ "files": [],
16
+ "include": [],
17
+ "references": [
18
+ {
19
+ "path": "./tsconfig.lib.json"
20
+ },
21
+ {
22
+ "path": "./tsconfig.spec.json"
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "outDir": "../../dist/out-tsc",
6
+ "declaration": true,
7
+ "types": ["node", "vite/client"]
8
+ },
9
+ "include": ["src/**/*.ts"],
10
+ "exclude": [
11
+ "vite.config.ts",
12
+ "vite.config.mts",
13
+ "vitest.config.ts",
14
+ "vitest.config.mts",
15
+ "src/**/*.test.ts",
16
+ "src/**/*.spec.ts",
17
+ "src/**/*.test.tsx",
18
+ "src/**/*.spec.tsx",
19
+ "src/**/*.test.js",
20
+ "src/**/*.spec.js",
21
+ "src/**/*.test.jsx",
22
+ "src/**/*.spec.jsx"
23
+ ]
24
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "types": [
6
+ "vitest/globals",
7
+ "vitest/importMeta",
8
+ "vite/client",
9
+ "node",
10
+ "vitest"
11
+ ]
12
+ },
13
+ "include": [
14
+ "vite.config.ts",
15
+ "vite.config.mts",
16
+ "vitest.config.ts",
17
+ "vitest.config.mts",
18
+ "src/**/*.test.ts",
19
+ "src/**/*.spec.ts",
20
+ "src/**/*.test.tsx",
21
+ "src/**/*.spec.tsx",
22
+ "src/**/*.test.js",
23
+ "src/**/*.spec.js",
24
+ "src/**/*.test.jsx",
25
+ "src/**/*.spec.jsx",
26
+ "src/**/*.d.ts"
27
+ ]
28
+ }
@@ -0,0 +1,59 @@
1
+ /// <reference types='vitest' />
2
+ import { defineConfig } from 'vite';
3
+ import dts from 'vite-plugin-dts';
4
+ import * as path from 'path';
5
+ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
6
+ import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
7
+
8
+ export default defineConfig(() => ({
9
+ root: import.meta.dirname,
10
+ cacheDir: '../../node_modules/.vite/libs/mcp-worker',
11
+ plugins: [
12
+ nxViteTsPaths(),
13
+ nxCopyAssetsPlugin(['*.md']),
14
+ dts({
15
+ entryRoot: 'src',
16
+ tsconfigPath: path.join(import.meta.dirname, 'tsconfig.lib.json'),
17
+ pathsToAliases: false,
18
+ }),
19
+ ],
20
+ // Uncomment this if you are using workers.
21
+ // worker: {
22
+ // plugins: () => [ nxViteTsPaths() ],
23
+ // },
24
+ // Configuration for building your library.
25
+ // See: https://vite.dev/guide/build.html#library-mode
26
+ build: {
27
+ outDir: '../../dist/libs/mcp-worker',
28
+ emptyOutDir: true,
29
+ reportCompressedSize: true,
30
+ commonjsOptions: {
31
+ transformMixedEsModules: true,
32
+ },
33
+ lib: {
34
+ // Could also be a dictionary or array of multiple entry points.
35
+ entry: 'src/index.ts',
36
+ name: 'mcp-worker',
37
+ fileName: 'index',
38
+ // Change this to the formats you want to support.
39
+ // Don't forget to update your package.json as well.
40
+ formats: ['es' as const],
41
+ },
42
+ rollupOptions: {
43
+ // External packages that should not be bundled into your library.
44
+ external: [],
45
+ },
46
+ },
47
+ test: {
48
+ name: 'mcp-worker',
49
+ watch: false,
50
+ globals: true,
51
+ environment: 'node',
52
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
53
+ reporters: ['default'],
54
+ coverage: {
55
+ reportsDirectory: '../../coverage/libs/mcp-worker',
56
+ provider: 'v8' as const,
57
+ },
58
+ },
59
+ }));