@hypothesi/tauri-mcp-server 0.1.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.
@@ -0,0 +1,334 @@
1
+ import { z } from 'zod';
2
+ import { getPluginClient, connectPlugin } from './plugin-client.js';
3
+ import { buildScreenshotScript } from './scripts/html2canvas-loader.js';
4
+ /**
5
+ * WebView Executor - Native IPC-based JavaScript execution
6
+ *
7
+ * This module provides native Tauri IPC-based execution,
8
+ * enabling cross-platform support (Linux, Windows, macOS) without external dependencies.
9
+ *
10
+ * Communication flow:
11
+ * MCP Server (Node.js) → plugin-client (WebSocket) → mcp-bridge plugin → Tauri Webview
12
+ */
13
+ // ============================================================================
14
+ // Auto-Initialization System
15
+ // ============================================================================
16
+ let isInitialized = false;
17
+ /**
18
+ * Ensures the MCP server is fully initialized and ready to use.
19
+ * This is called automatically by all tool functions.
20
+ *
21
+ * Initialization includes:
22
+ * - Connecting to the plugin WebSocket
23
+ * - Console capture is already initialized by bridge.js in the Tauri app
24
+ *
25
+ * This function is idempotent - calling it multiple times is safe.
26
+ */
27
+ export async function ensureReady() {
28
+ if (isInitialized) {
29
+ return;
30
+ }
31
+ // Connect to the plugin
32
+ await connectPlugin();
33
+ isInitialized = true;
34
+ }
35
+ /**
36
+ * Reset initialization state (useful for testing or reconnecting).
37
+ */
38
+ export function resetInitialization() {
39
+ isInitialized = false;
40
+ }
41
+ // ============================================================================
42
+ // Core Execution Functions
43
+ // ============================================================================
44
+ /**
45
+ * Execute JavaScript in the Tauri webview using native IPC via WebSocket.
46
+ *
47
+ * @param script - JavaScript code to execute in the webview context
48
+ * @returns Result of the script execution as a string
49
+ */
50
+ export async function executeInWebview(script) {
51
+ try {
52
+ // Ensure we're fully initialized
53
+ await ensureReady();
54
+ const client = getPluginClient();
55
+ // Send script directly - Rust handles wrapping and IPC callbacks.
56
+ // Use 7s timeout (longer than Rust's 5s) so errors return before Node times out.
57
+ const response = await client.sendCommand({
58
+ command: 'execute_js',
59
+ args: { script },
60
+ }, 7000);
61
+ // console.log('executeInWebview response:', JSON.stringify(response));
62
+ if (!response.success) {
63
+ throw new Error(response.error || 'Unknown execution error');
64
+ }
65
+ // Parse and return the result
66
+ const result = response.data;
67
+ // console.log('executeInWebview result data:', result, 'type:', typeof result);
68
+ if (result === null || result === undefined) {
69
+ return 'null';
70
+ }
71
+ if (typeof result === 'string') {
72
+ return result;
73
+ }
74
+ return JSON.stringify(result);
75
+ }
76
+ catch (error) {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ throw new Error(`WebView execution failed: ${message}`);
79
+ }
80
+ }
81
+ /**
82
+ * Execute async JavaScript in the webview with timeout support.
83
+ *
84
+ * @param script - JavaScript code to execute (can use await)
85
+ * @param timeout - Timeout in milliseconds (default: 5000)
86
+ * @returns Result of the script execution
87
+ */
88
+ export async function executeAsyncInWebview(script, timeout = 5000) {
89
+ const wrappedScript = `
90
+ return (async () => {
91
+ const timeoutPromise = new Promise((_, reject) => {
92
+ setTimeout(() => reject(new Error('Script execution timeout')), ${timeout});
93
+ });
94
+
95
+ const scriptPromise = (async () => {
96
+ ${script}
97
+ })();
98
+
99
+ return await Promise.race([scriptPromise, timeoutPromise]);
100
+ })();
101
+ `;
102
+ return executeInWebview(wrappedScript);
103
+ }
104
+ // ============================================================================
105
+ // Console Log Capture System
106
+ // ============================================================================
107
+ /**
108
+ * Initialize console log capture in the webview.
109
+ * This intercepts console methods and stores logs in memory.
110
+ *
111
+ * NOTE: Console capture is now automatically initialized by bridge.js when the
112
+ * Tauri app starts. This function is kept for backwards compatibility and will
113
+ * simply return early if capture is already initialized.
114
+ */
115
+ export async function initializeConsoleCapture() {
116
+ const script = `
117
+ if (!window.__MCP_CONSOLE_LOGS__) {
118
+ window.__MCP_CONSOLE_LOGS__ = [];
119
+ const originalConsole = { ...console };
120
+
121
+ ['log', 'debug', 'info', 'warn', 'error'].forEach(level => {
122
+ console[level] = function(...args) {
123
+ window.__MCP_CONSOLE_LOGS__.push({
124
+ level: level,
125
+ message: args.map(a => {
126
+ try {
127
+ return typeof a === 'object' ? JSON.stringify(a) : String(a);
128
+ } catch(e) {
129
+ return String(a);
130
+ }
131
+ }).join(' '),
132
+ timestamp: Date.now()
133
+ });
134
+
135
+ // Keep original console behavior
136
+ originalConsole[level].apply(console, args);
137
+ };
138
+ });
139
+
140
+ return 'Console capture initialized';
141
+ }
142
+ return 'Console capture already initialized';
143
+ `;
144
+ return executeInWebview(script);
145
+ }
146
+ /**
147
+ * Retrieve captured console logs with optional filtering.
148
+ *
149
+ * @param filter - Optional regex pattern to filter log messages
150
+ * @param since - Optional ISO timestamp to filter logs after this time
151
+ * @returns Formatted console logs as string
152
+ */
153
+ export async function getConsoleLogs(filter, since) {
154
+ const filterStr = filter ? filter.replace(/'/g, '\\\'') : '';
155
+ const sinceStr = since || '';
156
+ const script = `
157
+ const logs = window.__MCP_CONSOLE_LOGS__ || [];
158
+ let filtered = logs;
159
+
160
+ if ('${sinceStr}') {
161
+ const sinceTime = new Date('${sinceStr}').getTime();
162
+ filtered = filtered.filter(l => l.timestamp > sinceTime);
163
+ }
164
+
165
+ if ('${filterStr}') {
166
+ try {
167
+ const regex = new RegExp('${filterStr}', 'i');
168
+ filtered = filtered.filter(l => regex.test(l.message));
169
+ } catch(e) {
170
+ throw new Error('Invalid filter regex: ' + e.message);
171
+ }
172
+ }
173
+
174
+ return filtered.map(l =>
175
+ '[ ' + new Date(l.timestamp).toISOString() + ' ] [ ' + l.level.toUpperCase() + ' ] ' + l.message
176
+ ).join('\\n');
177
+ `;
178
+ return executeInWebview(script);
179
+ }
180
+ /**
181
+ * Clear all captured console logs.
182
+ */
183
+ export async function clearConsoleLogs() {
184
+ const script = `
185
+ window.__MCP_CONSOLE_LOGS__ = [];
186
+ return 'Console logs cleared';
187
+ `;
188
+ return executeInWebview(script);
189
+ }
190
+ // ============================================================================
191
+ // Screenshot Functionality
192
+ // ============================================================================
193
+ /**
194
+ * Capture a screenshot of the entire webview.
195
+ *
196
+ * @param format - Image format: 'png' or 'jpeg'
197
+ * @param quality - JPEG quality (0-100), only used for jpeg format
198
+ * @returns Base64-encoded image data URL
199
+ */
200
+ export async function captureScreenshot(format = 'png', quality = 90) {
201
+ // Primary implementation: Use native platform-specific APIs
202
+ // - macOS: WKWebView takeSnapshot
203
+ // - Windows: WebView2 CapturePreview
204
+ // - Linux: Chromium/WebKit screenshot APIs
205
+ try {
206
+ // Ensure we're fully initialized
207
+ await ensureReady();
208
+ const client = getPluginClient();
209
+ // Use longer timeout (15s) for native screenshot - the Rust code waits up to 10s
210
+ const response = await client.sendCommand({
211
+ command: 'capture_native_screenshot',
212
+ args: {
213
+ format,
214
+ quality,
215
+ },
216
+ }, 15000);
217
+ if (response.success && response.data) {
218
+ // The native command returns a base64 data URL
219
+ const dataUrl = response.data;
220
+ // Validate that we got a real data URL
221
+ if (dataUrl && dataUrl.startsWith('data:image/')) {
222
+ return `Webview screenshot captured (native):\n\n![Screenshot](${dataUrl})`;
223
+ }
224
+ }
225
+ // If we get here, native returned but with invalid data - throw to trigger fallback
226
+ throw new Error(response.error || 'Native screenshot returned invalid data');
227
+ }
228
+ catch (nativeError) {
229
+ // Log the native error for debugging, then fall back
230
+ const nativeMsg = nativeError instanceof Error ? nativeError.message : String(nativeError);
231
+ console.error(`Native screenshot failed: ${nativeMsg}, falling back to html2canvas`);
232
+ }
233
+ // Fallback 1: Use html2canvas library for high-quality DOM rendering
234
+ // The library is bundled from node_modules, not loaded from CDN
235
+ const html2canvasScript = buildScreenshotScript(format, quality);
236
+ // Fallback: Try Screen Capture API if available
237
+ // Note: This script is wrapped by executeAsyncInWebview, so we don't need an IIFE
238
+ const screenCaptureScript = `
239
+ // Check if Screen Capture API is available
240
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
241
+ throw new Error('Screen Capture API not available');
242
+ }
243
+
244
+ // Request screen capture permission and get the stream
245
+ const stream = await navigator.mediaDevices.getDisplayMedia({
246
+ video: {
247
+ displaySurface: 'window',
248
+ cursor: 'never'
249
+ },
250
+ audio: false
251
+ });
252
+
253
+ // Get the video track
254
+ const videoTrack = stream.getVideoTracks()[0];
255
+ if (!videoTrack) {
256
+ throw new Error('No video track available');
257
+ }
258
+
259
+ // Create a video element to display the stream
260
+ const video = document.createElement('video');
261
+ video.srcObject = stream;
262
+ video.autoplay = true;
263
+
264
+ // Wait for the video to load metadata
265
+ await new Promise((resolve, reject) => {
266
+ video.onloadedmetadata = resolve;
267
+ video.onerror = reject;
268
+ setTimeout(() => reject(new Error('Video load timeout')), 5000);
269
+ });
270
+
271
+ // Play the video
272
+ await video.play();
273
+
274
+ // Create canvas to capture the frame
275
+ const canvas = document.createElement('canvas');
276
+ const ctx = canvas.getContext('2d');
277
+
278
+ // Set canvas dimensions to match video
279
+ canvas.width = video.videoWidth;
280
+ canvas.height = video.videoHeight;
281
+
282
+ // Draw the video frame to canvas
283
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
284
+
285
+ // Stop all tracks to release the capture
286
+ stream.getTracks().forEach(track => track.stop());
287
+
288
+ // Convert to data URL with specified format and quality
289
+ const mimeType = '${format}' === 'jpeg' ? 'image/jpeg' : 'image/png';
290
+ return canvas.toDataURL(mimeType, ${quality / 100});
291
+ `;
292
+ try {
293
+ // Try html2canvas second (after native APIs)
294
+ const result = await executeAsyncInWebview(html2canvasScript, 10000); // Longer timeout for library loading
295
+ // Validate that we got a real data URL, not 'null' or empty
296
+ if (result && result !== 'null' && result.startsWith('data:image/')) {
297
+ return `Webview screenshot captured:\n\n![Screenshot](${result})`;
298
+ }
299
+ throw new Error(`html2canvas returned invalid result: ${result?.substring(0, 100) || 'null'}`);
300
+ }
301
+ catch (html2canvasError) {
302
+ try {
303
+ // Fallback to Screen Capture API
304
+ const result = await executeAsyncInWebview(screenCaptureScript);
305
+ // Validate that we got a real data URL
306
+ if (result && result.startsWith('data:image/')) {
307
+ return `Webview screenshot captured (via Screen Capture API):\n\n![Screenshot](${result})`;
308
+ }
309
+ throw new Error(`Screen Capture API returned invalid result: ${result?.substring(0, 50) || 'null'}`);
310
+ }
311
+ catch (screenCaptureError) {
312
+ // All methods failed - throw a proper error
313
+ const html2canvasMsg = html2canvasError instanceof Error ? html2canvasError.message : 'html2canvas failed';
314
+ const screenCaptureMsg = screenCaptureError instanceof Error ? screenCaptureError.message : 'Screen Capture API failed';
315
+ throw new Error('Screenshot capture failed. Native API not available, ' +
316
+ `html2canvas error: ${html2canvasMsg}, ` +
317
+ `Screen Capture API error: ${screenCaptureMsg}`);
318
+ }
319
+ }
320
+ }
321
+ // ============================================================================
322
+ // Schemas for Validation
323
+ // ============================================================================
324
+ export const ExecuteScriptSchema = z.object({
325
+ script: z.string().describe('JavaScript code to execute in the webview'),
326
+ });
327
+ export const GetConsoleLogsSchema = z.object({
328
+ filter: z.string().optional().describe('Regex or keyword to filter logs'),
329
+ since: z.string().optional().describe('ISO timestamp to filter logs since'),
330
+ });
331
+ export const CaptureScreenshotSchema = z.object({
332
+ format: z.enum(['png', 'jpeg']).optional().default('png').describe('Image format'),
333
+ quality: z.number().min(0).max(100).optional().describe('JPEG quality (0-100)'),
334
+ });
@@ -0,0 +1,213 @@
1
+ import { z } from 'zod';
2
+ import { executeInWebview, captureScreenshot, getConsoleLogs as getConsoleLogsFromCapture } from './webview-executor.js';
3
+ import { SCRIPTS, buildScript, buildTypeScript, buildKeyEventScript } from './scripts/index.js';
4
+ // ============================================================================
5
+ // Schemas
6
+ // ============================================================================
7
+ export const InteractSchema = z.object({
8
+ action: z.enum(['click', 'double-click', 'long-press', 'scroll', 'swipe'])
9
+ .describe('Type of interaction to perform'),
10
+ selector: z.string().optional().describe('CSS selector for the element to interact with'),
11
+ x: z.number().optional().describe('X coordinate for direct coordinate interaction'),
12
+ y: z.number().optional().describe('Y coordinate for direct coordinate interaction'),
13
+ duration: z.number().optional()
14
+ .describe('Duration in ms for long-press or swipe (default: 500ms for long-press, 300ms for swipe)'),
15
+ scrollX: z.number().optional().describe('Horizontal scroll amount in pixels (positive = right)'),
16
+ scrollY: z.number().optional().describe('Vertical scroll amount in pixels (positive = down)'),
17
+ // Swipe-specific parameters
18
+ fromX: z.number().optional().describe('Starting X coordinate for swipe'),
19
+ fromY: z.number().optional().describe('Starting Y coordinate for swipe'),
20
+ toX: z.number().optional().describe('Ending X coordinate for swipe'),
21
+ toY: z.number().optional().describe('Ending Y coordinate for swipe'),
22
+ });
23
+ export const ScreenshotSchema = z.object({
24
+ format: z.enum(['png', 'jpeg']).optional().default('png').describe('Image format'),
25
+ quality: z.number().min(0).max(100).optional().describe('JPEG quality (0-100, only for jpeg format)'),
26
+ });
27
+ export const KeyboardSchema = z.object({
28
+ action: z.enum(['type', 'press', 'down', 'up'])
29
+ .describe('Keyboard action type: "type" for typing text into an element, "press/down/up" for key events'),
30
+ selector: z.string().optional().describe('CSS selector for element to type into (required for "type" action)'),
31
+ text: z.string().optional().describe('Text to type (required for "type" action)'),
32
+ key: z.string().optional().describe('Key to press (required for "press/down/up" actions, e.g., "Enter", "a", "Escape")'),
33
+ modifiers: z.array(z.enum(['Control', 'Alt', 'Shift', 'Meta'])).optional().describe('Modifier keys to hold'),
34
+ });
35
+ export const WaitForSchema = z.object({
36
+ type: z.enum(['selector', 'text', 'ipc-event']).describe('What to wait for'),
37
+ value: z.string().describe('Selector, text content, or IPC event name to wait for'),
38
+ timeout: z.number().optional().default(5000).describe('Timeout in milliseconds (default: 5000ms)'),
39
+ });
40
+ export const GetStylesSchema = z.object({
41
+ selector: z.string().describe('CSS selector for element(s) to get styles from'),
42
+ properties: z.array(z.string()).optional().describe('Specific CSS properties to retrieve. If omitted, returns all computed styles'),
43
+ multiple: z.boolean().optional().default(false)
44
+ .describe('Whether to get styles for all matching elements (true) or just the first (false)'),
45
+ });
46
+ export const ExecuteJavaScriptSchema = z.object({
47
+ script: z.string().describe('JavaScript code to execute in the webview context'),
48
+ args: z.array(z.unknown()).optional().describe('Arguments to pass to the script'),
49
+ });
50
+ export const FocusElementSchema = z.object({
51
+ selector: z.string().describe('CSS selector for element to focus'),
52
+ });
53
+ export const FindElementSchema = z.object({
54
+ selector: z.string(),
55
+ strategy: z.enum(['css', 'xpath', 'text']).default('css'),
56
+ });
57
+ export const GetConsoleLogsSchema = z.object({
58
+ filter: z.string().optional().describe('Regex or keyword to filter logs'),
59
+ since: z.string().optional().describe('ISO timestamp to filter logs since'),
60
+ });
61
+ // ============================================================================
62
+ // Implementation Functions
63
+ // ============================================================================
64
+ export async function interact(options) {
65
+ const { action, selector, x, y, duration, scrollX, scrollY, fromX, fromY, toX, toY } = options;
66
+ // Handle swipe action separately since it has different logic
67
+ if (action === 'swipe') {
68
+ return performSwipe(fromX, fromY, toX, toY, duration);
69
+ }
70
+ const script = buildScript(SCRIPTS.interact, {
71
+ action,
72
+ selector: selector ?? null,
73
+ x: x ?? null,
74
+ y: y ?? null,
75
+ duration: duration ?? 500,
76
+ scrollX: scrollX ?? 0,
77
+ scrollY: scrollY ?? 0,
78
+ });
79
+ try {
80
+ return await executeInWebview(script);
81
+ }
82
+ catch (error) {
83
+ const message = error instanceof Error ? error.message : String(error);
84
+ throw new Error(`Interaction failed: ${message}`);
85
+ }
86
+ }
87
+ async function performSwipe(fromX, fromY, toX, toY, duration = 300) {
88
+ if (fromX === undefined || fromY === undefined || toX === undefined || toY === undefined) {
89
+ throw new Error('Swipe action requires fromX, fromY, toX, and toY coordinates');
90
+ }
91
+ const script = buildScript(SCRIPTS.swipe, { fromX, fromY, toX, toY, duration });
92
+ try {
93
+ return await executeInWebview(script);
94
+ }
95
+ catch (error) {
96
+ const message = error instanceof Error ? error.message : String(error);
97
+ throw new Error(`Swipe failed: ${message}`);
98
+ }
99
+ }
100
+ export async function screenshot(quality, format = 'png') {
101
+ // Use the native screenshot function from webview-executor
102
+ return captureScreenshot(format, quality);
103
+ }
104
+ export async function keyboard(action, selectorOrKey, textOrModifiers, modifiers) {
105
+ // Handle the different parameter combinations based on action
106
+ if (action === 'type') {
107
+ const selector = selectorOrKey;
108
+ const text = textOrModifiers;
109
+ if (!selector || !text) {
110
+ throw new Error('Type action requires both selector and text parameters');
111
+ }
112
+ const script = buildTypeScript(selector, text);
113
+ try {
114
+ return await executeInWebview(script);
115
+ }
116
+ catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ throw new Error(`Type action failed: ${message}`);
119
+ }
120
+ }
121
+ // For press/down/up actions: key is required, modifiers optional
122
+ const key = selectorOrKey;
123
+ const mods = Array.isArray(textOrModifiers) ? textOrModifiers : modifiers;
124
+ if (!key) {
125
+ throw new Error(`${action} action requires a key parameter`);
126
+ }
127
+ const script = buildKeyEventScript(action, key, mods || []);
128
+ try {
129
+ return await executeInWebview(script);
130
+ }
131
+ catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ throw new Error(`Keyboard action failed: ${message}`);
134
+ }
135
+ }
136
+ export async function waitFor(type, value, timeout = 5000) {
137
+ const script = buildScript(SCRIPTS.waitFor, { type, value, timeout });
138
+ try {
139
+ return await executeInWebview(script);
140
+ }
141
+ catch (error) {
142
+ const message = error instanceof Error ? error.message : String(error);
143
+ throw new Error(`Wait failed: ${message}`);
144
+ }
145
+ }
146
+ export async function getStyles(selector, properties, multiple = false) {
147
+ const script = buildScript(SCRIPTS.getStyles, {
148
+ selector,
149
+ properties: properties || [],
150
+ multiple,
151
+ });
152
+ try {
153
+ return await executeInWebview(script);
154
+ }
155
+ catch (error) {
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ throw new Error(`Get styles failed: ${message}`);
158
+ }
159
+ }
160
+ export async function executeJavaScript(script, args) {
161
+ // If args are provided, we need to inject them into the script context
162
+ const wrappedScript = args && args.length > 0
163
+ ? `
164
+ (function() {
165
+ const args = ${JSON.stringify(args)};
166
+ return (${script}).apply(null, args);
167
+ })();
168
+ `
169
+ : script;
170
+ try {
171
+ const result = await executeInWebview(wrappedScript);
172
+ return result;
173
+ }
174
+ catch (error) {
175
+ const message = error instanceof Error ? error.message : String(error);
176
+ throw new Error(`JavaScript execution failed: ${message}`);
177
+ }
178
+ }
179
+ export async function focusElement(selector) {
180
+ const script = buildScript(SCRIPTS.focus, { selector });
181
+ try {
182
+ return await executeInWebview(script);
183
+ }
184
+ catch (error) {
185
+ const message = error instanceof Error ? error.message : String(error);
186
+ throw new Error(`Focus failed: ${message}`);
187
+ }
188
+ }
189
+ /**
190
+ * Find an element using various selector strategies.
191
+ */
192
+ export async function findElement(selector, strategy) {
193
+ const script = buildScript(SCRIPTS.findElement, { selector, strategy });
194
+ try {
195
+ return await executeInWebview(script);
196
+ }
197
+ catch (error) {
198
+ const message = error instanceof Error ? error.message : String(error);
199
+ throw new Error(`Find element failed: ${message}`);
200
+ }
201
+ }
202
+ /**
203
+ * Get console logs from the webview.
204
+ */
205
+ export async function getConsoleLogs(filter, since) {
206
+ try {
207
+ return await getConsoleLogsFromCapture(filter, since);
208
+ }
209
+ catch (error) {
210
+ const message = error instanceof Error ? error.message : String(error);
211
+ throw new Error(`Failed to get console logs: ${message}`);
212
+ }
213
+ }
package/dist/index.js ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
+ import { zodToJsonSchema } from 'zod-to-json-schema';
6
+ import { readFileSync } from 'fs';
7
+ import { fileURLToPath } from 'url';
8
+ import { dirname, join } from 'path';
9
+ // Import the single source of truth for all tools
10
+ import { TOOLS, TOOL_MAP } from './tools-registry.js';
11
+ /* eslint-disable no-process-exit */
12
+ // Read version from package.json
13
+ const currentDir = dirname(fileURLToPath(import.meta.url));
14
+ const packageJson = JSON.parse(readFileSync(join(currentDir, '..', 'package.json'), 'utf-8'));
15
+ const VERSION = packageJson.version;
16
+ // Initialize server
17
+ const server = new Server({
18
+ name: 'mcp-server-tauri',
19
+ version: VERSION,
20
+ }, {
21
+ capabilities: {
22
+ tools: {},
23
+ },
24
+ });
25
+ // Handle connection errors gracefully - don't crash on broken pipe
26
+ server.onerror = (error) => {
27
+ // Ignore broken pipe errors - they happen when the client disconnects
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ if (message.includes('broken pipe') || message.includes('EPIPE')) {
30
+ // Client disconnected, exit gracefully
31
+ process.exit(0);
32
+ }
33
+ // For other errors, log to stderr (will be captured by MCP client)
34
+ console.error('[MCP Server Error]', message);
35
+ };
36
+ // Handle connection close - exit gracefully
37
+ server.onclose = () => {
38
+ process.exit(0);
39
+ };
40
+ // Tool list handler - generated from registry
41
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
42
+ return {
43
+ tools: TOOLS.map((tool) => {
44
+ return {
45
+ name: tool.name,
46
+ description: tool.description,
47
+ inputSchema: zodToJsonSchema(tool.schema),
48
+ };
49
+ }),
50
+ };
51
+ });
52
+ // Tool call handler - generated from registry
53
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
54
+ try {
55
+ const tool = TOOL_MAP.get(request.params.name);
56
+ if (!tool) {
57
+ throw new Error(`Unknown tool: ${request.params.name}`);
58
+ }
59
+ const output = await tool.handler(request.params.arguments);
60
+ return { content: [{ type: 'text', text: output }] };
61
+ }
62
+ catch (error) {
63
+ const message = error instanceof Error ? error.message : String(error);
64
+ return {
65
+ content: [{ type: 'text', text: `Error: ${message}` }],
66
+ isError: true,
67
+ };
68
+ }
69
+ });
70
+ // Start server
71
+ async function main() {
72
+ const transport = new StdioServerTransport();
73
+ await server.connect(transport);
74
+ // Don't log to stderr - it interferes with MCP protocol
75
+ }
76
+ main().catch(() => {
77
+ // Don't log errors to stderr - just exit silently
78
+ // The error will be in the MCP response if needed
79
+ process.exit(1);
80
+ });