Skip to content

Commit a18349d

Browse files
committed
Implement multi-instance WebSocket server with leader election
- Add port-based leader election for WebSocket server instances - Create SharedStateService for filesystem-based state persistence - Refactor services with proper separation of concerns - Add comprehensive test suite using Node.js built-in test runner - Rename WebSocketMessageType to PointerMessageType for clarity - Configure changesets with linked packages for version alignment - Bump all packages to version 0.3.0
1 parent a1e114c commit a18349d

10 files changed

Lines changed: 509 additions & 16 deletions

File tree

packages/server/CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
# @mcp-pointer/server
22

3+
## 0.3.0
4+
5+
### Minor Changes
6+
7+
- feat(server): Add multi-instance support with port-based leader election
8+
9+
- Implement port-based leader election for WebSocket server management
10+
- Add shared state persistence to filesystem (/tmp/mcp-pointer-shared-state.json)
11+
- Support multiple MCP server instances without port conflicts
12+
- Add automatic failover when leader instance crashes (~5 second recovery)
13+
- Refactor services into dedicated service layer (WebSocketService, MCPService, SharedStateService)
14+
- Add comprehensive test suite using Node.js built-in test runner
15+
- Add architecture documentation with Mermaid diagram to CONTRIBUTING.md
16+
- Rename WebSocketMessageType to PointerMessageType for better domain clarity
17+
- Add proper process cleanup handling on Ctrl+C and other signals
18+
- MCP service now runs independently on all instances (leader and followers)
19+
20+
Breaking changes: None - fully backwards compatible with single instance deployments
21+
22+
### Patch Changes
23+
24+
- Updated dependencies
25+
- @mcp-pointer/shared@0.3.0
26+
327
## 0.2.0
428

529
### Minor Changes

packages/server/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mcp-pointer/server",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "MCP Server for DOM element pointing - WebSocket bridge for element targeting",
55
"type": "module",
66
"main": "dist/index.js",
@@ -14,7 +14,8 @@
1414
"typecheck": "tsc --noEmit",
1515
"configure": "tsx src/cli.ts configure",
1616
"link:global": "pnpm build && pnpm link --global",
17-
"build:start": "node dist/cli.cjs start"
17+
"build:start": "node dist/cli.cjs start",
18+
"test": "tsx --test src/__tests__/**/*.test.ts"
1819
},
1920
"dependencies": {
2021
"@mcp-pointer/shared": "workspace:*",
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import {
2+
describe, it, before, after,
3+
} from 'node:test';
4+
import assert from 'node:assert';
5+
import fs from 'fs/promises';
6+
import SharedStateService from '../../services/shared-state-service';
7+
import {
8+
setupTestDir, cleanupTestFiles, createMockElement, TEST_SHARED_STATE_PATH,
9+
} from '../test-helpers';
10+
11+
// Override the shared state path for testing
12+
13+
describe('SharedStateService', () => {
14+
let service: SharedStateService;
15+
16+
before(async () => {
17+
await setupTestDir();
18+
19+
// Monkey-patch the constant for testing
20+
const SharedStateServiceModule = await import('../../services/shared-state-service');
21+
(SharedStateServiceModule.default as any).prototype.constructor = function testConstructor() {
22+
// Use test path instead of default
23+
this.filePath = TEST_SHARED_STATE_PATH;
24+
};
25+
26+
service = new SharedStateService();
27+
});
28+
29+
after(async () => {
30+
await cleanupTestFiles();
31+
});
32+
33+
it('should save and load current element', async () => {
34+
const mockElement = createMockElement();
35+
36+
await service.saveCurrentElement(mockElement);
37+
const loaded = await service.getCurrentElement();
38+
39+
assert.deepStrictEqual(loaded, mockElement);
40+
});
41+
42+
it('should handle null element', async () => {
43+
await service.saveCurrentElement(null);
44+
const loaded = await service.getCurrentElement();
45+
46+
assert.strictEqual(loaded, null);
47+
});
48+
49+
it('should return null for missing file', async () => {
50+
await cleanupTestFiles();
51+
await setupTestDir();
52+
53+
const loaded = await service.getCurrentElement();
54+
55+
assert.strictEqual(loaded, null);
56+
});
57+
58+
it('should handle corrupted file gracefully', async () => {
59+
// Write corrupted data to the file
60+
await fs.writeFile(TEST_SHARED_STATE_PATH, 'not json at all', 'utf8');
61+
62+
const loaded = await service.getCurrentElement();
63+
64+
assert.strictEqual(loaded, null);
65+
});
66+
67+
it('should save element over corrupted file', async () => {
68+
// First create a corrupted file
69+
await fs.writeFile(TEST_SHARED_STATE_PATH, 'corrupted content', 'utf8');
70+
71+
// Save a new element over it
72+
const mockElement = createMockElement();
73+
await service.saveCurrentElement(mockElement);
74+
75+
// Should be able to load the new element
76+
const loaded = await service.getCurrentElement();
77+
78+
assert.deepStrictEqual(loaded, mockElement);
79+
});
80+
81+
it('should overwrite previous element', async () => {
82+
const firstElement = createMockElement();
83+
const secondElement = { ...createMockElement(), id: 'second-element' };
84+
85+
await service.saveCurrentElement(firstElement);
86+
await service.saveCurrentElement(secondElement);
87+
88+
const loaded = await service.getCurrentElement();
89+
90+
assert.deepStrictEqual(loaded, secondElement);
91+
});
92+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import fs from 'fs/promises';
2+
import path from 'path';
3+
import { fileURLToPath } from 'url';
4+
import { type TargetedElement } from '@mcp-pointer/shared';
5+
6+
// ES module equivalent of __dirname
7+
// eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle
8+
const __filename = fileURLToPath(import.meta.url);
9+
// eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle
10+
const __dirname = path.dirname(__filename);
11+
12+
// Test constants
13+
export const TEST_MCP_POINTER_PORT = 7008;
14+
export const TEST_TEMP_DIR = path.join(__dirname, 'tmp');
15+
export const TEST_SHARED_STATE_PATH = path.join(TEST_TEMP_DIR, 'mcp-pointer-test-shared-state.json');
16+
17+
export async function setupTestDir(): Promise<void> {
18+
try {
19+
await fs.mkdir(TEST_TEMP_DIR, { recursive: true });
20+
} catch {
21+
// Directory exists, ignore
22+
}
23+
}
24+
25+
export async function cleanupTestFiles(): Promise<void> {
26+
try {
27+
await fs.rm(TEST_TEMP_DIR, { recursive: true, force: true });
28+
} catch {
29+
// Directory doesn't exist, ignore
30+
}
31+
}
32+
33+
export function createMockElement(): TargetedElement {
34+
return {
35+
selector: 'div.test-element',
36+
tagName: 'DIV',
37+
id: 'test-id',
38+
classes: ['test-class'],
39+
innerText: 'Test Element',
40+
attributes: { 'data-test': 'true' },
41+
position: {
42+
x: 100, y: 200, width: 300, height: 50,
43+
},
44+
cssProperties: {
45+
display: 'block',
46+
position: 'relative',
47+
fontSize: '16px',
48+
color: 'rgb(0, 0, 0)',
49+
backgroundColor: 'rgb(255, 255, 255)',
50+
},
51+
timestamp: Date.now(),
52+
url: 'https://example.com',
53+
tabId: 123,
54+
};
55+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3+
import {
4+
CallToolRequestSchema,
5+
ListToolsRequestSchema,
6+
} from '@modelcontextprotocol/sdk/types.js';
7+
import { version } from 'process';
8+
import SharedStateService from './shared-state-service';
9+
10+
enum MCPToolName {
11+
GET_POINTED_ELEMENT = 'get-pointed-element',
12+
}
13+
14+
enum MCPServerName {
15+
MCP_POINTER = 'mcp-pointer',
16+
}
17+
18+
export default class MCPService {
19+
private server: Server;
20+
21+
private sharedState: SharedStateService;
22+
23+
constructor(sharedState: SharedStateService) {
24+
this.sharedState = sharedState;
25+
this.server = new Server(
26+
{
27+
name: MCPServerName.MCP_POINTER,
28+
version,
29+
},
30+
{
31+
capabilities: {
32+
tools: {},
33+
},
34+
},
35+
);
36+
37+
this.setupHandlers();
38+
}
39+
40+
private setupHandlers(): void {
41+
this.server.setRequestHandler(ListToolsRequestSchema, this.handleListTools.bind(this));
42+
this.server.setRequestHandler(CallToolRequestSchema, this.handleCallTool.bind(this));
43+
}
44+
45+
private async handleListTools() {
46+
return {
47+
tools: [
48+
{
49+
name: MCPToolName.GET_POINTED_ELEMENT,
50+
description: 'Get information about the currently pointed/shown DOM element from the browser extension, in order to let you see a specific element the user is showing you on his/her the browser.',
51+
inputSchema: {
52+
type: 'object',
53+
properties: {},
54+
required: [],
55+
},
56+
},
57+
],
58+
};
59+
}
60+
61+
private async handleCallTool(request: any) {
62+
if (request.params.name === MCPToolName.GET_POINTED_ELEMENT) {
63+
return this.getTargetedElement();
64+
}
65+
66+
throw new Error(`Unknown tool: ${request.params.name}`);
67+
}
68+
69+
private async getTargetedElement() {
70+
const element = await this.sharedState.getCurrentElement();
71+
72+
if (!element) {
73+
return {
74+
content: [
75+
{
76+
type: 'text',
77+
text: 'No element is currently pointed. '
78+
+ 'The user needs to point an element in their browser using Option+Click.',
79+
},
80+
],
81+
};
82+
}
83+
84+
return {
85+
content: [
86+
{
87+
type: 'text',
88+
text: JSON.stringify(element, null, 2),
89+
},
90+
],
91+
};
92+
}
93+
94+
public async start(): Promise<void> {
95+
const transport = new StdioServerTransport();
96+
await this.server.connect(transport);
97+
}
98+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import fs from 'fs/promises';
2+
import { type TargetedElement } from '@mcp-pointer/shared';
3+
import logger from '../logger';
4+
5+
// Shared state constants
6+
const SHARED_STATE_FILE_PATH = '/tmp/mcp-pointer-shared-state.json';
7+
8+
export default class SharedStateService {
9+
public async saveCurrentElement(element: TargetedElement | null): Promise<void> {
10+
try {
11+
const json = JSON.stringify(element, null, 2);
12+
await fs.writeFile(SHARED_STATE_FILE_PATH, json, 'utf8');
13+
logger.debug('Current element saved to shared state file');
14+
} catch (error) {
15+
logger.error('Failed to save current element:', error);
16+
}
17+
}
18+
19+
public async getCurrentElement(): Promise<TargetedElement | null> {
20+
try {
21+
const json = await fs.readFile(SHARED_STATE_FILE_PATH, 'utf8');
22+
return JSON.parse(json);
23+
} catch (error) {
24+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
25+
logger.debug('Shared state file does not exist');
26+
return null;
27+
}
28+
logger.error('Failed to load current element:', error);
29+
return null;
30+
}
31+
}
32+
}

0 commit comments

Comments
 (0)