@imenam/mcp-gui-interface 1.0.3 → 1.0.5
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 +288 -0
- package/dist/src/gui-launcher.js +1 -1
- package/dist/src/proxy-client.d.ts +1 -1
- package/dist/src/proxy-client.js +12 -12
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# @imenam/mcp-gui-interface
|
|
2
|
+
|
|
3
|
+
Shared library for MCP GUI lifecycle management — spawning, IPC, proxy registration, and logging.
|
|
4
|
+
|
|
5
|
+
This package encapsulates the boilerplate every MCP server needs when it runs a companion GUI: spawning a child process safely, communicating with it over IPC, registering it with the central proxy, and logging without polluting stdout.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @imenam/mcp-gui-interface
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
No runtime dependencies. Requires Node.js with ESM support (`"type": "module"`).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Why this library exists
|
|
20
|
+
|
|
21
|
+
MCP servers communicate with their host (Cursor, Claude Desktop, etc.) over **stdout** using JSON-RPC. Any `console.log` or stray output on stdout will corrupt the protocol and break the connection.
|
|
22
|
+
|
|
23
|
+
When an MCP server also needs to serve a GUI (typically an Express server), it must:
|
|
24
|
+
|
|
25
|
+
1. Spawn the GUI as a **separate child process** so its stdout doesn't pollute the parent's JSON-RPC channel.
|
|
26
|
+
2. Keep the two processes in sync via **typed IPC messages**.
|
|
27
|
+
3. **Register the GUI** with a central proxy so it can be reached at a predictable URL.
|
|
28
|
+
4. **Log safely** — always to stderr and/or a log file, never to stdout.
|
|
29
|
+
|
|
30
|
+
This library handles all four concerns.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## API Reference
|
|
35
|
+
|
|
36
|
+
### `GuiLauncher`
|
|
37
|
+
|
|
38
|
+
Spawns and supervises a GUI child process.
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { GuiLauncher } from "@imenam/mcp-gui-interface";
|
|
42
|
+
|
|
43
|
+
const launcher = new GuiLauncher({
|
|
44
|
+
guiPath: "./dist/gui.js", // Path to the GUI entry point (forked with Node)
|
|
45
|
+
env: { PORT: "3000" }, // Extra environment variables passed to the child
|
|
46
|
+
maxRestarts: 3, // Max auto-restarts on crash (default: 3)
|
|
47
|
+
restartDelay: 2000, // Delay in ms before restarting (default: 2000)
|
|
48
|
+
onMessage: (msg) => { // Optional callback for incoming IPC messages
|
|
49
|
+
console.error("Received:", msg);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
launcher.start();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Options (`GuiLauncherOptions`)**
|
|
57
|
+
|
|
58
|
+
| Property | Type | Default | Description |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| `guiPath` | `string` | — | Absolute or relative path to the GUI script to fork |
|
|
61
|
+
| `env` | `Record<string, string>` | `{}` | Additional env vars merged with `process.env` |
|
|
62
|
+
| `maxRestarts` | `number` | `3` | Maximum number of auto-restarts after unexpected crashes |
|
|
63
|
+
| `restartDelay` | `number` | `2000` | Milliseconds to wait before each restart attempt |
|
|
64
|
+
| `onMessage` | `(msg: IpcMessage) => void` | — | Callback invoked when the GUI sends an IPC message |
|
|
65
|
+
|
|
66
|
+
**Methods**
|
|
67
|
+
|
|
68
|
+
| Method | Returns | Description |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `start()` | `ChildProcess` | Spawns (or re-spawns) the GUI process |
|
|
71
|
+
| `send(message)` | `boolean` | Fire-and-forget IPC message to the GUI |
|
|
72
|
+
| `getIpcHub()` | `IpcHub` | Access the underlying `IpcHub` for advanced usage |
|
|
73
|
+
| `getProcess()` | `ChildProcess \| null` | The current child process instance |
|
|
74
|
+
| `cleanup()` | `Promise<void>` | Gracefully terminates the GUI (SIGTERM → SIGKILL after 3s) |
|
|
75
|
+
|
|
76
|
+
The launcher automatically handles `SIGINT`, `SIGTERM`, and stdin `close` events so the GUI is always cleaned up when the MCP server exits.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
### `IpcHub`
|
|
81
|
+
|
|
82
|
+
Typed IPC messaging between the MCP server (parent) and the GUI (child).
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import { IpcHub } from "@imenam/mcp-gui-interface";
|
|
86
|
+
|
|
87
|
+
// Parent side (access via GuiLauncher.getIpcHub())
|
|
88
|
+
const ipc = launcher.getIpcHub();
|
|
89
|
+
|
|
90
|
+
// Fire-and-forget
|
|
91
|
+
ipc.send({ type: "CONFIG_UPDATE", data: { theme: "dark" }, timestamp: new Date().toISOString() });
|
|
92
|
+
|
|
93
|
+
// Request/response with correlation ID and timeout
|
|
94
|
+
const response = await ipc.request({ type: "GET_STATUS" }, 3000);
|
|
95
|
+
console.error(response.data);
|
|
96
|
+
|
|
97
|
+
// Subscribe to all incoming messages
|
|
98
|
+
ipc.onMessage((msg) => {
|
|
99
|
+
if (msg.type === "READY") console.error("GUI is ready");
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**`IpcMessage` interface**
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
interface IpcMessage {
|
|
107
|
+
type: string; // Message type identifier
|
|
108
|
+
correlationId?: string; // Auto-set by request() for matching responses
|
|
109
|
+
data?: any; // Payload
|
|
110
|
+
error?: string; // Error description (used in error responses)
|
|
111
|
+
timestamp: string; // ISO 8601 timestamp
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Methods**
|
|
116
|
+
|
|
117
|
+
| Method | Description |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `send(message)` | Sends a typed message. Returns `false` if the process is not connected. |
|
|
120
|
+
| `onMessage(callback)` | Subscribes to all incoming messages from the target process. |
|
|
121
|
+
| `request(message, timeout?)` | Sends a message and waits for a response with a matching `correlationId`. Rejects on timeout (default: 2000ms) or if the process is disconnected. |
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
### `ProxyClient`
|
|
126
|
+
|
|
127
|
+
Registers and unregisters the GUI with the central HTTP proxy ([`mcp-http-gateway`](https://github.com/Matthieu-Pesnot-Pin/mcp-http-gateway)).
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
import { ProxyClient } from "@imenam/mcp-gui-interface";
|
|
131
|
+
|
|
132
|
+
const proxy = new ProxyClient("http://localhost:4242");
|
|
133
|
+
|
|
134
|
+
// Register — proxy allocates a port and returns it
|
|
135
|
+
const result = await proxy.register(
|
|
136
|
+
{ path: "/my-app", name: "my-app" },
|
|
137
|
+
fallbackPort // Used if proxy is unreachable
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (result.success) {
|
|
141
|
+
console.error(`GUI available at port ${result.port}`);
|
|
142
|
+
} else {
|
|
143
|
+
console.error(`Running on fallback port ${fallbackPort}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check current status
|
|
147
|
+
proxy.getStatus(); // "connected" | "fallback" | "error"
|
|
148
|
+
|
|
149
|
+
// Unregister on shutdown
|
|
150
|
+
await proxy.unregister();
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**`RegisterOptions`**
|
|
154
|
+
|
|
155
|
+
| Property | Type | Description |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `path` | `string` | The URL path to register (e.g. `/my-app`) |
|
|
158
|
+
| `name` | `string` | Optional display name for the app |
|
|
159
|
+
| `port` | `number` | Optional preferred port |
|
|
160
|
+
|
|
161
|
+
**`RegisterResult`**
|
|
162
|
+
|
|
163
|
+
| Property | Type | Description |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| `success` | `boolean` | Whether registration succeeded |
|
|
166
|
+
| `port` | `number` | Allocated port (or `fallbackPort` on failure) |
|
|
167
|
+
| `url` | `string \| undefined` | Full URL returned by the proxy |
|
|
168
|
+
| `error` | `string \| undefined` | Error description on failure |
|
|
169
|
+
|
|
170
|
+
The proxy registration has a 1-second timeout. If the proxy is unreachable, `status` is set to `"fallback"` and the fallback port is used — the GUI still starts normally.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
### `setupLogging` / `createLogger`
|
|
175
|
+
|
|
176
|
+
Safe logging for MCP servers, keeping stdout clean for JSON-RPC.
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
import { setupLogging, createLogger } from "@imenam/mcp-gui-interface";
|
|
180
|
+
|
|
181
|
+
// Call once at startup in the MCP server process
|
|
182
|
+
setupLogging({
|
|
183
|
+
processLabel: "MY-MCP", // Label used in log file entries
|
|
184
|
+
logDir: "./logs", // Optional. Default: .mcp-gui/logs/
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Create scoped loggers anywhere in the codebase
|
|
188
|
+
const logger = createLogger("MyModule");
|
|
189
|
+
|
|
190
|
+
logger.info("Server started");
|
|
191
|
+
logger.warn("Config missing, using defaults");
|
|
192
|
+
logger.error("Failed to connect", { reason: "timeout" });
|
|
193
|
+
logger.debug("Verbose detail");
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**What `setupLogging` does:**
|
|
197
|
+
- Redirects `console.log`, `console.info`, `console.warn` → `console.error` (protecting stdout).
|
|
198
|
+
- Patches `console.error` to also append every line to a rotating log file at `.mcp-gui/logs/server.log`.
|
|
199
|
+
- Log entries include ISO timestamp, PID, and process label.
|
|
200
|
+
|
|
201
|
+
**`SetupLoggingOptions`**
|
|
202
|
+
|
|
203
|
+
| Property | Type | Default | Description |
|
|
204
|
+
|---|---|---|---|
|
|
205
|
+
| `processLabel` | `string` | — | Label shown in log file lines |
|
|
206
|
+
| `logDir` | `string` | `.mcp-gui/logs` | Directory where `server.log` is written |
|
|
207
|
+
|
|
208
|
+
**Log format:**
|
|
209
|
+
```
|
|
210
|
+
[2026-03-10T12:00:00.000Z] [PID 1234] [MY-MCP] [INFO] [MyModule] Server started
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Complete usage example
|
|
216
|
+
|
|
217
|
+
Here is a typical MCP server that uses all four features:
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { GuiLauncher, ProxyClient, setupLogging, createLogger } from "@imenam/mcp-gui-interface";
|
|
221
|
+
|
|
222
|
+
// 1. Safe logging (must be called first)
|
|
223
|
+
setupLogging({ processLabel: "MY-MCP" });
|
|
224
|
+
const logger = createLogger("Main");
|
|
225
|
+
|
|
226
|
+
// 2. Register the GUI with the proxy
|
|
227
|
+
const proxy = new ProxyClient(process.env.PROXY_URL ?? "http://localhost:4242");
|
|
228
|
+
const result = await proxy.register({ path: "/my-app", name: "my-app" }, 3001);
|
|
229
|
+
const port = result.port;
|
|
230
|
+
|
|
231
|
+
// 3. Launch the GUI child process
|
|
232
|
+
const launcher = new GuiLauncher({
|
|
233
|
+
guiPath: new URL("./gui.js", import.meta.url).pathname,
|
|
234
|
+
env: { PORT: String(port) },
|
|
235
|
+
onMessage: (msg) => {
|
|
236
|
+
if (msg.type === "READY") logger.info("GUI reported ready");
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
launcher.start();
|
|
241
|
+
logger.info(`GUI process started on port ${port}`);
|
|
242
|
+
|
|
243
|
+
// 4. Two-way IPC: request data from the GUI
|
|
244
|
+
const ipc = launcher.getIpcHub();
|
|
245
|
+
const status = await ipc.request({ type: "GET_STATUS" });
|
|
246
|
+
logger.info("GUI status:", status.data);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Type reference
|
|
252
|
+
|
|
253
|
+
All public types are exported from the package root:
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
import type {
|
|
257
|
+
GuiLauncherOptions,
|
|
258
|
+
IpcMessage,
|
|
259
|
+
RegisterOptions,
|
|
260
|
+
RegisterResult,
|
|
261
|
+
ProxyConfig,
|
|
262
|
+
Logger,
|
|
263
|
+
SetupLoggingOptions,
|
|
264
|
+
} from "@imenam/mcp-gui-interface";
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Development
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
# Build
|
|
273
|
+
npm run build
|
|
274
|
+
|
|
275
|
+
# Watch mode
|
|
276
|
+
npm run build:watch
|
|
277
|
+
|
|
278
|
+
# Release (patch bump, build, publish, push tag)
|
|
279
|
+
npm run release
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The compiled output and type declarations are in `dist/`.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## License
|
|
287
|
+
|
|
288
|
+
ISC © [Matthieu Pesnot-Pin](https://github.com/Matthieu-Pesnot-Pin)
|
package/dist/src/gui-launcher.js
CHANGED
|
@@ -11,7 +11,7 @@ export class GuiLauncher {
|
|
|
11
11
|
restartDelay;
|
|
12
12
|
constructor(options) {
|
|
13
13
|
this.options = options;
|
|
14
|
-
this.maxRestarts = options.maxRestarts ??
|
|
14
|
+
this.maxRestarts = options.maxRestarts ?? 0;
|
|
15
15
|
this.restartDelay = options.restartDelay ?? 2000;
|
|
16
16
|
this.registerSignalHandlers();
|
|
17
17
|
}
|
|
@@ -6,6 +6,6 @@ export declare class ProxyClient {
|
|
|
6
6
|
private status;
|
|
7
7
|
constructor(proxyUrl: string);
|
|
8
8
|
getStatus(): "connected" | "fallback" | "error";
|
|
9
|
-
register(options: RegisterOptions
|
|
9
|
+
register(options: RegisterOptions): Promise<RegisterResult>;
|
|
10
10
|
unregister(): Promise<boolean>;
|
|
11
11
|
}
|
package/dist/src/proxy-client.js
CHANGED
|
@@ -11,7 +11,7 @@ export class ProxyClient {
|
|
|
11
11
|
getStatus() {
|
|
12
12
|
return this.status;
|
|
13
13
|
}
|
|
14
|
-
async register(options
|
|
14
|
+
async register(options) {
|
|
15
15
|
this.registeredPath = options.path;
|
|
16
16
|
return new Promise((resolve) => {
|
|
17
17
|
let url;
|
|
@@ -21,7 +21,7 @@ export class ProxyClient {
|
|
|
21
21
|
catch {
|
|
22
22
|
logger.error(`Invalid Proxy URL: ${this.proxyUrl}`);
|
|
23
23
|
this.status = "error";
|
|
24
|
-
return resolve({ success: false, port:
|
|
24
|
+
return resolve({ success: false, port: 0, error: "Invalid Proxy URL" });
|
|
25
25
|
}
|
|
26
26
|
const timeout = 1000;
|
|
27
27
|
logger.info(`Sending registration request to ${url.href}...`);
|
|
@@ -49,33 +49,33 @@ export class ProxyClient {
|
|
|
49
49
|
}
|
|
50
50
|
else {
|
|
51
51
|
logger.error(`Invalid proxy response schema: ${data}`);
|
|
52
|
-
this.status = "
|
|
53
|
-
resolve({ success: false, port:
|
|
52
|
+
this.status = "error";
|
|
53
|
+
resolve({ success: false, port: 0, error: "Invalid proxy response schema" });
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
catch {
|
|
57
57
|
logger.error(`JSON parse error: ${data}`);
|
|
58
|
-
this.status = "
|
|
59
|
-
resolve({ success: false, port:
|
|
58
|
+
this.status = "error";
|
|
59
|
+
resolve({ success: false, port: 0, error: "Invalid JSON response" });
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
else {
|
|
63
63
|
logger.error(`Registration rejected with HTTP ${res.statusCode}`);
|
|
64
|
-
this.status = "
|
|
65
|
-
resolve({ success: false, port:
|
|
64
|
+
this.status = "error";
|
|
65
|
+
resolve({ success: false, port: 0, error: `HTTP ${res.statusCode}` });
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
69
|
req.on("error", (err) => {
|
|
70
70
|
logger.error(`Registration error: ${err.message}`);
|
|
71
|
-
this.status = "
|
|
72
|
-
resolve({ success: false, port:
|
|
71
|
+
this.status = "error";
|
|
72
|
+
resolve({ success: false, port: 0, error: err.message });
|
|
73
73
|
});
|
|
74
74
|
req.on("timeout", () => {
|
|
75
75
|
logger.error(`Registration timeout after ${timeout}ms`);
|
|
76
76
|
req.destroy();
|
|
77
|
-
this.status = "
|
|
78
|
-
resolve({ success: false, port:
|
|
77
|
+
this.status = "error";
|
|
78
|
+
resolve({ success: false, port: 0, error: "Timeout" });
|
|
79
79
|
});
|
|
80
80
|
req.write(JSON.stringify(options));
|
|
81
81
|
req.end();
|
package/package.json
CHANGED