@imenam/mcp-gui-interface 1.0.13 → 1.0.15

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 CHANGED
@@ -1,369 +1,376 @@
1
- # @imenam/mcp-gui-interface
2
-
3
- Shared library for MCP GUI lifecycle management — spawning, IPC, proxy registration, logging, and network exposure rules.
4
-
5
- This package encapsulates the boilerplate many MCP servers need when they run a companion GUI: spawning a child process safely, communicating over IPC, registering with the central HTTP proxy, and logging without polluting **stdout** (reserved for MCP JSON-RPC on the parent process).
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"` in consuming packages).
16
-
17
- ---
18
-
19
- ## Scope: Node only
20
-
21
- This package targets **Node.js processes** (MCP master and GUI worker). It is **not** meant for browser or Vite bundles — do not add it as a dependency of a React/Vite front-end.
22
-
23
- ---
24
-
25
- ## How it fits together
26
-
27
- 1. **MCP host** (e.g. Cursor) talks **only** to the **master** process over **stdio** (JSON-RPC). Nothing from this library runs in the host.
28
- 2. The **master** may use **`GuiLauncher`** to **fork** a second Node process: the **GUI worker**. They talk over Node **IPC** (`process.send` / `message`).
29
- 3. The **worker** starts an HTTP server (Express, Hono, …) and usually uses **`ProxyClient`** to register with your **HTTP proxy** so the UI gets a stable URL. It also serves the built SPA (static files).
30
- 4. The **browser** loads that SPA. It talks to the worker via **HTTP** (`fetch`, SSE). It does **not** import this npm package—the worker is already a Node server.
31
-
32
- So: **two Node processes** (master + worker) may both depend on this package; the **browser bundle** must not.
33
-
34
- ```mermaid
35
- flowchart LR
36
- Host[MCP host]
37
- Master[Master process]
38
- Worker[GUI worker Node]
39
- Proxy[HTTP proxy]
40
- Browser[Browser]
41
-
42
- Host <-->|stdio JSON-RPC| Master
43
- Master -->|fork + IPC| Worker
44
- Worker -->|register| Proxy
45
- Browser -->|HTTP| Proxy
46
- Proxy --> Worker
47
- ```
48
-
49
- For a longer French walkthrough, see **MCP Documentor** → project **`mcp-gui-interface`** → *Guide d’utilisation — @imenam/mcp-gui-interface*.
50
-
51
- ---
52
-
53
- ## Why this library exists
54
-
55
- MCP servers talk to the host (Cursor, Claude Desktop, etc.) over **stdout** using JSON-RPC. Any stray `console.log` on stdout can corrupt the protocol.
56
-
57
- When an MCP server also serves a GUI (Express, Hono, etc.), it typically must:
58
-
59
- 1. **Fork** the GUI as a **separate child process** so the GUI’s stdout does not mix with the parent’s JSON-RPC channel.
60
- 2. Exchange messages over **Node IPC** (`process.send` / `message`).
61
- 3. **Register** the GUI with the central proxy ([mcp-http-gateway](https://github.com/Matthieu-Pesnot-Pin/mcp-http-gateway)) so it is reachable under a stable path.
62
- 4. **Log safely** — stderr and/or a log file; never rely on stdout for logs in the master process.
63
-
64
- This library supports these patterns via `GuiLauncher`, `IpcHub`, `ProxyClient`, and `setupLogging` / `createLogger`.
65
-
66
- ---
67
-
68
- ## API reference
69
-
70
- ### `GuiLauncher`
71
-
72
- Spawns and supervises a GUI child process (`fork` with `stdio: ['ignore', 'pipe', 'inherit', 'ipc']` — child stdout is piped to the parent’s stderr).
73
-
74
- ```typescript
75
- import { GuiLauncher } from "@imenam/mcp-gui-interface";
76
-
77
- const launcher = new GuiLauncher({
78
- guiPath: "./dist/gui-worker.js",
79
- env: { ...process.env } as Record<string, string>,
80
- maxRestarts: 0, // default: 0 — no auto-restart unless you opt in
81
- restartDelay: 2000, // default: 2000 ms between restart attempts
82
- onMessage: (msg) => {
83
- console.error("Received:", msg);
84
- },
85
- });
86
-
87
- launcher.start();
88
- ```
89
-
90
- **`GuiLauncherOptions`**
91
-
92
- | Property | Type | Default | Description |
93
- |----------------|------------------------------|---------|-------------|
94
- | `guiPath` | `string` | — | Path to the GUI entry script (forked with Node) |
95
- | `env` | `Record<string, string>` | — | Extra env vars merged with `process.env` |
96
- | `maxRestarts` | `number` | **`0`** | Auto-restarts after unexpected exit (non-zero code) |
97
- | `restartDelay` | `number` | `2000` | Ms to wait before each restart |
98
- | `onMessage` | `(msg: IpcMessage) => void` | — | Incoming IPC from the child |
99
-
100
- **Methods**
101
-
102
- | Method | Returns | Description |
103
- |------------------|----------------------|-------------|
104
- | `start()` | `ChildProcess` | Spawns or respawns the GUI |
105
- | `send(message)` | `boolean` | Sends IPC to the child (timestamp added internally) |
106
- | `getIpcHub()` | `IpcHub` | Lower-level IPC helper |
107
- | `getProcess()` | `ChildProcess \| null` | Current child, if any |
108
- | `cleanup()` | `Promise<void>` | SIGTERM, then SIGKILL after 3s if needed |
109
-
110
- The launcher registers handlers for `SIGINT`, `SIGTERM`, parent `exit`, and stdin `close` to tear down the child.
111
-
112
- ---
113
-
114
- ### `IpcHub`
115
-
116
- Typed IPC between parent and child. Usually obtained via `launcher.getIpcHub()`.
117
-
118
- ```typescript
119
- const ipc = launcher.getIpcHub();
120
-
121
- ipc.send({
122
- type: "CONFIG_UPDATE",
123
- data: { theme: "dark" },
124
- timestamp: new Date().toISOString(),
125
- });
126
-
127
- const response = await ipc.request({ type: "GET_STATUS" }, 3000);
128
- ```
129
-
130
- **`IpcMessage`**
131
-
132
- ```typescript
133
- interface IpcMessage {
134
- type: string;
135
- correlationId?: string;
136
- data?: any;
137
- error?: string;
138
- timestamp: string;
139
- }
140
- ```
141
-
142
- **Methods**
143
-
144
- | Method | Description |
145
- |----------------------------|-------------|
146
- | `send(message)` | Returns `false` if not connected |
147
- | `onMessage(callback)` | Only delivers objects with a `type` field |
148
- | `request(message, timeout?)` | Default timeout **2000 ms** |
149
-
150
- ---
151
-
152
- ### `ProxyClient`
153
-
154
- Registers / unregisters the GUI with the proxy (`POST /proxy/register`, `DELETE /proxy/unregister`).
155
-
156
- **Current API (source / recent releases):**
157
-
158
- ```typescript
159
- import { ProxyClient } from "@imenam/mcp-gui-interface";
160
-
161
- const proxy = new ProxyClient(process.env.PROXY_URL!);
162
-
163
- const result = await proxy.register({ path: "/my-app", name: "My App" });
164
-
165
- if (result.success) {
166
- console.error(`Listening on port ${result.port}`, result.url);
167
- } else {
168
- console.error(result.error, result.port); // port is 0 on failure
169
- }
170
-
171
- await proxy.unregister();
172
- ```
173
-
174
- - Registration HTTP timeout: **1000 ms** (unregister: **2000 ms**).
175
- - On failure, `RegisterResult.port` is **`0`** in the current implementation.
176
-
177
- #### Path & name resolution (`APP_PATH`, `APP_PATH_PREFIX`, …)
178
-
179
- `register({ path })` treats `path` as the MCP's **natural** path — the default
180
- hardcoded in its own source. The effective path is resolved from the
181
- environment, and comes back in `result.path`:
182
-
183
- | Variable | Role |
184
- |---|---|
185
- | `APP_PATH` | **Canonical.** Explicit mount path. Replaces the natural path. |
186
- | `PROXY_APP_PATH` | Fallback (Express MCPs), used when `APP_PATH` is unset. |
187
- | `GUI_PATH` | Fallback (gitHubMCP), used when the two above are unset. |
188
- | `APP_PATH_PREFIX` | Namespace prefixed to the **natural** path. |
189
- | `APP_NAME` / `PROXY_APP_NAME` / `GUI_NAME` | Display name, same priority order. |
190
- | `APP_GROUP` | Proxy dashboard group (empty value = ungrouped). |
191
-
192
- **The rule, exactly:**
193
-
194
- 1. If any of `APP_PATH` → `PROXY_APP_PATH` → `GUI_PATH` is set to a non-empty
195
- value, that value is the path, **taken as-is and never prefixed**.
196
- 2. Otherwise the natural path is used, with `APP_PATH_PREFIX` prepended.
197
- 3. Both are normalised: leading `/` added, `//` collapsed, trailing `/` removed.
198
- An empty prefix, or `/`, has no effect.
199
- 4. Prefixing is idempotent — a path already under the prefix is left alone, so
200
- resolving twice is safe.
201
-
202
- **Why an explicit path is never prefixed.** `APP_PATH_PREFIX` exists for a
203
- supervisor that namespaces the GUIs it starts without knowing their natural
204
- paths. Someone who types `APP_PATH=/whatever` by hand is stating the *final*
205
- path and must get exactly that; the prefix only namespaces a value nobody
206
- chose. Setting `APP_PATH` is therefore also the way to opt out of prefixing.
207
-
208
- ```typescript
209
- // APP_PATH_PREFIX=/abc123, no APP_PATH:
210
- const r = await proxy.register({ path: "/documentor-gui" });
211
- r.path; // "/abc123/documentor-gui"
212
- r.url; // "http://localhost:3000/abc123/documentor-gui"
213
- ```
214
-
215
- > **Always build the SPA's `<base href>` and any public URL from `result.path` /
216
- > `result.url`**, never from the path you passed in — otherwise assets are
217
- > requested one path segment too high and the page loads blank.
218
-
219
- The same resolution is exported standalone, for code that needs the path before
220
- registering:
221
-
222
- ```typescript
223
- import { resolveAppPath, resolveAppName, resolveAppGroup } from "@imenam/mcp-gui-interface";
224
-
225
- const appPath = resolveAppPath("/documentor-gui"); // same rule, idempotent
226
- ```
227
-
228
- **Grouping (`group` / `APP_GROUP`):** pass an optional `group` in the register
229
- options to place the app under a collapsible section (folded by default) in the
230
- proxy dashboard. Apps sharing the same `group` are shown together; apps without
231
- one land in a default "ungrouped" section. When `group` is omitted, `register`
232
- falls back to the **`APP_GROUP`** environment variable, so an MCP can enable
233
- grouping purely from its `.env` without touching code:
234
-
235
- ```typescript
236
- // Explicit:
237
- await proxy.register({ path: "/my-app", name: "My App", group: "Google" });
238
-
239
- // Or, with APP_GROUP=Google in the environment, simply:
240
- await proxy.register({ path: "/my-app", name: "My App" });
241
- ```
242
-
243
- > **Older npm versions** may have exposed `register(options, fallbackPort)`. Check `node_modules/@imenam/mcp-gui-interface/dist/src/proxy-client.d.ts` for the exact signature you have installed.
244
-
245
- **`getStatus()`:** `"connected" | "fallback" | "error"` — many failure paths set `"error"`.
246
-
247
- ---
248
-
249
- ### `setupLogging` / `createLogger`
250
-
251
- Safe logging for MCP servers: protect stdout, mirror errors to a file.
252
-
253
- ```typescript
254
- import { setupLogging, createLogger } from "@imenam/mcp-gui-interface";
255
-
256
- setupLogging({
257
- processLabel: "MY-MCP",
258
- logDir: "./logs", // optional; overrides MCP_LOG_DIR env var and .mcp-gui/logs default
259
- });
260
-
261
- const logger = createLogger("MyModule");
262
- logger.info("Server started");
263
- ```
264
-
265
- **`setupLogging`**
266
-
267
- - Runs once per process (subsequent calls are no-ops).
268
- - Creates `logDir`, appends to **`server.log`** (single file — **no built-in rotation**).
269
- - Redirects `console.log` / `info` / `warn` → `console.error`.
270
- - Patches `console.error` to also append to the log file.
271
-
272
- **Log directory resolution order:**
273
-
274
- 1. `logDir` option passed to `setupLogging`
275
- 2. `MCP_LOG_DIR` environment variable
276
- 3. `.mcp-gui/logs` relative to `process.cwd()` (default)
277
-
278
- **`createLogger(scope)`** — logs to stderr with `[LEVEL] [scope] …`.
279
-
280
- ---
281
-
282
- ### Network exposure: `GUI_LISTEN_HOST` and the request guard
283
-
284
- A GUI worker must listen on the loopback interface only. Other machines reach it through the proxy or the gateway manager, which authenticate the user and relay to `localhost:<port>` / `127.0.0.1:<port>`.
285
-
286
- ```typescript
287
- import { GUI_LISTEN_HOST, honoGuiGuard, expressGuiGuard } from "@imenam/mcp-gui-interface";
288
-
289
- // Hono
290
- app.use("*", honoGuiGuard());
291
- serve({ fetch: app.fetch, port, hostname: GUI_LISTEN_HOST });
292
-
293
- // Express — register the guard before any route or body parser
294
- app.use(expressGuiGuard());
295
- app.listen(port, GUI_LISTEN_HOST);
296
- ```
297
-
298
- Never pass `"localhost"` as the listen host: depending on the system it resolves to `::1` or `127.0.0.1`. Never omit the host either: Node then listens on every interface.
299
-
300
- The guard refuses with a `403` JSON body `{ error }`:
301
-
302
- | Rule | Refused request | Protects against |
303
- |------|-----------------|------------------|
304
- | **Host** | a `Host` header other than `localhost`, `127.0.0.1` or `::1` | DNS rebinding: a hostile domain resolving to 127.0.0.1 |
305
- | **Cross-site modification** | a method other than GET/HEAD/OPTIONS with `Sec-Fetch-Site` other than `same-origin`/`none`; when that header is absent, an `Origin` whose hostname is neither this machine's (hostname, interface addresses, loopback), nor the proxy's, nor in `trustedOrigins` | CSRF: a web page submitting a form or a `fetch` to the GUI |
306
-
307
- Requests carrying neither `Sec-Fetch-Site` nor `Origin` (curl, scripts) pass the second rule. Reads are never refused on their origin: without CORS headers, the browser does not let another site read the responses. Do not add a CORS middleware to a GUI worker.
308
-
309
- **`GuiRequestGuardOptions`**
310
-
311
- | Property | Type | Default | Description |
312
- |------------------|------------|---------------------|-------------|
313
- | `proxyUrl` | `string` | `process.env.PROXY_URL` | Its hostname is a trusted origin |
314
- | `trustedOrigins` | `string[]` | `[]` | Other origins whose hostname is trusted, e.g. a gateway manager served over plain HTTP under a domain name |
315
-
316
- `checkGuiRequest(info, trustedOriginHostnames(options))` applies the same rules to any other server (for instance an `upgrade` handler).
317
-
318
- ---
319
-
320
- ## Full-stack usage sketch
321
-
322
- Typical split:
323
-
324
- 1. **Master:** `setupLogging`, optional `GuiLauncher` + `start()` when `PROXY_URL` (or your policy) allows.
325
- 2. **GUI worker:** `setupLogging`, request guard, `ProxyClient.register`, bind HTTP server to returned port on `GUI_LISTEN_HOST`, `unregister` on shutdown.
326
-
327
- The master example in older docs that called `ProxyClient` in the same process as `GuiLauncher` is valid for simple layouts; **Hono-based** MCPs (e.g. MCP Documentor) often register the proxy **only** in the worker.
328
-
329
- ---
330
-
331
- ## Exported types
332
-
333
- ```typescript
334
- import type {
335
- GuiLauncherOptions,
336
- IpcMessage,
337
- RegisterOptions,
338
- RegisterResult,
339
- ProxyConfig,
340
- Logger,
341
- SetupLoggingOptions,
342
- GuiRequestGuardOptions,
343
- GuiRequestInfo,
344
- GuiRequestRejection,
345
- } from "@imenam/mcp-gui-interface";
346
- ```
347
-
348
- ---
349
-
350
- ## Development
351
-
352
- ```bash
353
- npm run build # tsc → dist/
354
- npm run build:watch
355
- npm test # build, then node --test test/*.test.mjs
356
- npm run release # version patch, build, publish, push tags
357
- ```
358
-
359
- ---
360
-
361
- ## Documentation interne
362
-
363
- Un **guide d’utilisation** (comment intégrer la lib dans un MCP + GUI) est maintenu dans **MCP Documentor**, projet **`mcp-gui-interface`**, entrée *Guide d’utilisation — @imenam/mcp-gui-interface* (dossier `guide`).
364
-
365
- ---
366
-
367
- ## License
368
-
369
- ISC © [Matthieu Pesnot-Pin](https://github.com/Matthieu-Pesnot-Pin)
1
+ # @imenam/mcp-gui-interface
2
+
3
+ Shared library for MCP GUI lifecycle management — spawning, IPC, proxy registration, logging, and network exposure rules.
4
+
5
+ This package encapsulates the boilerplate many MCP servers need when they run a companion GUI: spawning a child process safely, communicating over IPC, registering with the central HTTP proxy, and logging without polluting **stdout** (reserved for MCP JSON-RPC on the parent process).
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"` in consuming packages).
16
+
17
+ ---
18
+
19
+ ## Scope: Node only
20
+
21
+ This package targets **Node.js processes** (MCP master and GUI worker). It is **not** meant for browser or Vite bundles — do not add it as a dependency of a React/Vite front-end.
22
+
23
+ ---
24
+
25
+ ## How it fits together
26
+
27
+ 1. **MCP host** (e.g. Cursor) talks **only** to the **master** process over **stdio** (JSON-RPC). Nothing from this library runs in the host.
28
+ 2. The **master** may use **`GuiLauncher`** to **fork** a second Node process: the **GUI worker**. They talk over Node **IPC** (`process.send` / `message`).
29
+ 3. The **worker** starts an HTTP server (Express, Hono, …) and usually uses **`ProxyClient`** to register with your **HTTP proxy** so the UI gets a stable URL. It also serves the built SPA (static files).
30
+ 4. The **browser** loads that SPA. It talks to the worker via **HTTP** (`fetch`, SSE). It does **not** import this npm package—the worker is already a Node server.
31
+
32
+ So: **two Node processes** (master + worker) may both depend on this package; the **browser bundle** must not.
33
+
34
+ ```mermaid
35
+ flowchart LR
36
+ Host[MCP host]
37
+ Master[Master process]
38
+ Worker[GUI worker Node]
39
+ Proxy[HTTP proxy]
40
+ Browser[Browser]
41
+
42
+ Host <-->|stdio JSON-RPC| Master
43
+ Master -->|fork + IPC| Worker
44
+ Worker -->|register| Proxy
45
+ Browser -->|HTTP| Proxy
46
+ Proxy --> Worker
47
+ ```
48
+
49
+ For a longer French walkthrough, see **MCP Documentor** → project **`mcp-gui-interface`** → *Guide d’utilisation — @imenam/mcp-gui-interface*.
50
+
51
+ ---
52
+
53
+ ## Why this library exists
54
+
55
+ MCP servers talk to the host (Cursor, Claude Desktop, etc.) over **stdout** using JSON-RPC. Any stray `console.log` on stdout can corrupt the protocol.
56
+
57
+ When an MCP server also serves a GUI (Express, Hono, etc.), it typically must:
58
+
59
+ 1. **Fork** the GUI as a **separate child process** so the GUI’s stdout does not mix with the parent’s JSON-RPC channel.
60
+ 2. Exchange messages over **Node IPC** (`process.send` / `message`).
61
+ 3. **Register** the GUI with the central proxy ([mcp-http-gateway](https://github.com/Matthieu-Pesnot-Pin/mcp-http-gateway)) so it is reachable under a stable path.
62
+ 4. **Log safely** — stderr and/or a log file; never rely on stdout for logs in the master process.
63
+
64
+ This library supports these patterns via `GuiLauncher`, `IpcHub`, `ProxyClient`, and `setupLogging` / `createLogger`.
65
+
66
+ ---
67
+
68
+ ## API reference
69
+
70
+ ### `GuiLauncher`
71
+
72
+ Spawns and supervises a GUI child process (`fork` with `stdio: ['ignore', 'pipe', 'inherit', 'ipc']` — child stdout is piped to the parent’s stderr).
73
+
74
+ ```typescript
75
+ import { GuiLauncher } from "@imenam/mcp-gui-interface";
76
+
77
+ const launcher = new GuiLauncher({
78
+ guiPath: "./dist/gui-worker.js",
79
+ env: { ...process.env } as Record<string, string>,
80
+ maxRestarts: 0, // default: 0 — no auto-restart unless you opt in
81
+ restartDelay: 2000, // default: 2000 ms between restart attempts
82
+ onMessage: (msg) => {
83
+ console.error("Received:", msg);
84
+ },
85
+ });
86
+
87
+ launcher.start();
88
+ ```
89
+
90
+ **`GuiLauncherOptions`**
91
+
92
+ | Property | Type | Default | Description |
93
+ |----------------|------------------------------|---------|-------------|
94
+ | `guiPath` | `string` | — | Path to the GUI entry script (forked with Node) |
95
+ | `env` | `Record<string, string>` | — | Extra env vars merged with `process.env` |
96
+ | `maxRestarts` | `number` | **`0`** | Auto-restarts after unexpected exit (non-zero code) |
97
+ | `restartDelay` | `number` | `2000` | Ms to wait before each restart |
98
+ | `onMessage` | `(msg: IpcMessage) => void` | — | Incoming IPC from the child |
99
+
100
+ **Methods**
101
+
102
+ | Method | Returns | Description |
103
+ |------------------|----------------------|-------------|
104
+ | `start()` | `ChildProcess` | Spawns or respawns the GUI |
105
+ | `send(message)` | `boolean` | Sends IPC to the child (timestamp added internally) |
106
+ | `getIpcHub()` | `IpcHub` | Lower-level IPC helper |
107
+ | `getProcess()` | `ChildProcess \| null` | Current child, if any |
108
+ | `cleanup()` | `Promise<void>` | SIGTERM, then SIGKILL after 3s if needed |
109
+
110
+ The launcher registers handlers for `SIGINT`, `SIGTERM`, parent `exit`, and stdin `close` to tear down the child.
111
+
112
+ ---
113
+
114
+ ### `IpcHub`
115
+
116
+ Typed IPC between parent and child. Usually obtained via `launcher.getIpcHub()`.
117
+
118
+ ```typescript
119
+ const ipc = launcher.getIpcHub();
120
+
121
+ ipc.send({
122
+ type: "CONFIG_UPDATE",
123
+ data: { theme: "dark" },
124
+ timestamp: new Date().toISOString(),
125
+ });
126
+
127
+ const response = await ipc.request({ type: "GET_STATUS" }, 3000);
128
+ ```
129
+
130
+ **`IpcMessage`**
131
+
132
+ ```typescript
133
+ interface IpcMessage {
134
+ type: string;
135
+ correlationId?: string;
136
+ data?: any;
137
+ error?: string;
138
+ timestamp: string;
139
+ }
140
+ ```
141
+
142
+ **Methods**
143
+
144
+ | Method | Description |
145
+ |----------------------------|-------------|
146
+ | `send(message)` | Returns `false` if not connected |
147
+ | `onMessage(callback)` | Only delivers objects with a `type` field |
148
+ | `request(message, timeout?)` | Default timeout **2000 ms** |
149
+
150
+ ---
151
+
152
+ ### `ProxyClient`
153
+
154
+ Registers / unregisters the GUI with the proxy (`POST /proxy/register`, `DELETE /proxy/unregister`).
155
+
156
+ **Current API (source / recent releases):**
157
+
158
+ ```typescript
159
+ import { ProxyClient } from "@imenam/mcp-gui-interface";
160
+
161
+ const proxy = new ProxyClient(process.env.PROXY_URL!);
162
+
163
+ const result = await proxy.register({ path: "/my-app", name: "My App" });
164
+
165
+ if (result.success) {
166
+ console.error(`Listening on port ${result.port}`, result.url);
167
+ } else {
168
+ console.error(result.error, result.port); // port is 0 on failure
169
+ }
170
+
171
+ await proxy.unregister();
172
+ ```
173
+
174
+ - Registration HTTP timeout: **1000 ms** (unregister: **2000 ms**).
175
+ - On failure, `RegisterResult.port` is **`0`** in the current implementation.
176
+
177
+ #### Path & name resolution (`APP_PATH`, `APP_PATH_PREFIX`, …)
178
+
179
+ `register({ path })` treats `path` as the MCP's **natural** path — the default
180
+ hardcoded in its own source. The effective path is resolved from the
181
+ environment, and comes back in `result.path`:
182
+
183
+ | Variable | Role |
184
+ |---|---|
185
+ | `APP_PATH` | **Canonical.** Explicit mount path. Replaces the natural path. |
186
+ | `PROXY_APP_PATH` | Fallback (Express MCPs), used when `APP_PATH` is unset. |
187
+ | `GUI_PATH` | Fallback (gitHubMCP), used when the two above are unset. |
188
+ | `APP_PATH_PREFIX` | Namespace prefixed to the **natural** path. |
189
+ | `APP_NAME` / `PROXY_APP_NAME` / `GUI_NAME` | Display name, same priority order. |
190
+ | `APP_GROUP` | Proxy dashboard group (empty value = ungrouped). |
191
+
192
+ **The rule, exactly:**
193
+
194
+ 1. If any of `APP_PATH` → `PROXY_APP_PATH` → `GUI_PATH` is set to a non-empty
195
+ value, that value is the path, **taken as-is and never prefixed**.
196
+ 2. Otherwise the natural path is used, with `APP_PATH_PREFIX` prepended.
197
+ 3. Both are normalised: leading `/` added, `//` collapsed, trailing `/` removed.
198
+ An empty prefix, or `/`, has no effect.
199
+ 4. Prefixing is idempotent — a path already under the prefix is left alone, so
200
+ resolving twice is safe.
201
+
202
+ **Why an explicit path is never prefixed.** `APP_PATH_PREFIX` exists for a
203
+ supervisor that namespaces the GUIs it starts without knowing their natural
204
+ paths. Someone who types `APP_PATH=/whatever` by hand is stating the *final*
205
+ path and must get exactly that; the prefix only namespaces a value nobody
206
+ chose. Setting `APP_PATH` is therefore also the way to opt out of prefixing.
207
+
208
+ ```typescript
209
+ // APP_PATH_PREFIX=/abc123, no APP_PATH:
210
+ const r = await proxy.register({ path: "/documentor-gui" });
211
+ r.path; // "/abc123/documentor-gui"
212
+ r.url; // "http://localhost:3000/abc123/documentor-gui"
213
+ ```
214
+
215
+ > **Always build the SPA's `<base href>` and any public URL from `result.path` /
216
+ > `result.url`**, never from the path you passed in — otherwise assets are
217
+ > requested one path segment too high and the page loads blank.
218
+
219
+ The same resolution is exported standalone, for code that needs the path before
220
+ registering:
221
+
222
+ ```typescript
223
+ import { resolveAppPath, resolveAppName, resolveAppGroup } from "@imenam/mcp-gui-interface";
224
+
225
+ const appPath = resolveAppPath("/documentor-gui"); // same rule, idempotent
226
+ ```
227
+
228
+ **Grouping (`group` / `APP_GROUP`):** pass an optional `group` in the register
229
+ options to place the app under a collapsible section (folded by default) in the
230
+ proxy dashboard. Apps sharing the same `group` are shown together; apps without
231
+ one land in a default "ungrouped" section. When `group` is omitted, `register`
232
+ falls back to the **`APP_GROUP`** environment variable, so an MCP can enable
233
+ grouping purely from its `.env` without touching code:
234
+
235
+ ```typescript
236
+ // Explicit:
237
+ await proxy.register({ path: "/my-app", name: "My App", group: "Google" });
238
+
239
+ // Or, with APP_GROUP=Google in the environment, simply:
240
+ await proxy.register({ path: "/my-app", name: "My App" });
241
+ ```
242
+
243
+ > **Older npm versions** may have exposed `register(options, fallbackPort)`. Check `node_modules/@imenam/mcp-gui-interface/dist/src/proxy-client.d.ts` for the exact signature you have installed.
244
+
245
+ **`getStatus()`:** `"connected" | "fallback" | "error"` — many failure paths set `"error"`.
246
+
247
+ ---
248
+
249
+ ### `setupLogging` / `createLogger`
250
+
251
+ Safe logging for MCP servers: protect stdout, mirror errors to a file.
252
+
253
+ ```typescript
254
+ import { setupLogging, createLogger } from "@imenam/mcp-gui-interface";
255
+
256
+ setupLogging({
257
+ processLabel: "MY-MCP",
258
+ logDir: "./logs", // optional; overrides MCP_LOG_DIR env var and .mcp-gui/logs default
259
+ });
260
+
261
+ const logger = createLogger("MyModule");
262
+ logger.info("Server started");
263
+ ```
264
+
265
+ **`setupLogging`**
266
+
267
+ - Runs once per process (subsequent calls are no-ops).
268
+ - Creates `logDir`, appends to **`server.log`** (single file — **no built-in rotation**).
269
+ - Redirects `console.log` / `info` / `warn` → `console.error`.
270
+ - Patches `console.error` to also append to the log file.
271
+
272
+ **Log directory resolution order:**
273
+
274
+ 1. `logDir` option passed to `setupLogging`
275
+ 2. `MCP_LOG_DIR` environment variable
276
+ 3. `.mcp-gui/logs` relative to `process.cwd()` (default)
277
+
278
+ **`createLogger(scope)`** — logs to stderr with `[LEVEL] [scope] …`.
279
+
280
+ ---
281
+
282
+ ### Network exposure: `GUI_LISTEN_HOST` and the request guard
283
+
284
+ A GUI worker must listen on the loopback interface only. Other machines reach it through the proxy or the gateway manager, which authenticate the user and relay to `localhost:<port>` / `127.0.0.1:<port>`.
285
+
286
+ ```typescript
287
+ import { GUI_LISTEN_HOST, honoGuiGuard, expressGuiGuard } from "@imenam/mcp-gui-interface";
288
+
289
+ // Hono
290
+ app.use("*", honoGuiGuard());
291
+ serve({ fetch: app.fetch, port, hostname: GUI_LISTEN_HOST });
292
+
293
+ // Express — register the guard before any route or body parser
294
+ app.use(expressGuiGuard());
295
+ app.listen(port, GUI_LISTEN_HOST);
296
+ ```
297
+
298
+ Never pass `"localhost"` as the listen host: depending on the system it resolves to `::1` or `127.0.0.1`. Never omit the host either: Node then listens on every interface.
299
+
300
+ The guard refuses with a `403` JSON body `{ error }`:
301
+
302
+ | Rule | Refused request | Protects against |
303
+ |------|-----------------|------------------|
304
+ | **Host** | a `Host` header other than `localhost`, `127.0.0.1` or `::1` | DNS rebinding: a hostile domain resolving to 127.0.0.1 |
305
+ | **Cross-site modification** | a method other than GET/HEAD/OPTIONS with `Sec-Fetch-Site` other than `same-origin`/`none`; when that header is absent, an `Origin` whose hostname is neither this machine's (hostname, interface addresses, loopback), nor the proxy's, nor in `trustedOrigins` | CSRF: a web page submitting a form or a `fetch` to the GUI |
306
+
307
+ When the network interfaces cannot be listed (a systemd sandbox with `RestrictAddressFamilies` lacking `AF_NETLINK`), the guard logs a warning and does not trust this machine's IP addresses as origins; list them in `trustedOrigins` if the GUI is opened through them over plain HTTP.
308
+
309
+ Requests carrying neither `Sec-Fetch-Site` nor `Origin` (curl, scripts) pass the second rule. Reads are never refused on their origin: without CORS headers, the browser does not let another site read the responses. Do not add a CORS middleware to a GUI worker.
310
+
311
+ **`GuiRequestGuardOptions`**
312
+
313
+ | Property | Type | Default | Description |
314
+ |------------------|------------|---------------------|-------------|
315
+ | `proxyUrl` | `string` | `process.env.PROXY_URL` | Its hostname is a trusted origin |
316
+ | `trustedOrigins` | `string[]` | `[]` | Other origins whose hostname is trusted, e.g. a gateway manager served over plain HTTP under a domain name |
317
+
318
+ `checkGuiRequest(info, trustedOriginHostnames(options))` applies the same rules to any other server (for instance an `upgrade` handler).
319
+
320
+ ---
321
+
322
+ ## Full-stack usage sketch
323
+
324
+ Typical split:
325
+
326
+ 1. **Master:** `setupLogging`, optional `GuiLauncher` + `start()` when `PROXY_URL` (or your policy) allows.
327
+ 2. **GUI worker:** `setupLogging`, request guard, `ProxyClient.register`, bind HTTP server to returned port on `GUI_LISTEN_HOST`, `unregister` on shutdown.
328
+
329
+ The master example in older docs that called `ProxyClient` in the same process as `GuiLauncher` is valid for simple layouts; **Hono-based** MCPs (e.g. MCP Documentor) often register the proxy **only** in the worker.
330
+
331
+ ---
332
+
333
+ ## Exported types
334
+
335
+ ```typescript
336
+ import type {
337
+ GuiLauncherOptions,
338
+ IpcMessage,
339
+ RegisterOptions,
340
+ RegisterResult,
341
+ ProxyConfig,
342
+ Logger,
343
+ SetupLoggingOptions,
344
+ GuiRequestGuardOptions,
345
+ GuiRequestInfo,
346
+ GuiRequestRejection,
347
+ } from "@imenam/mcp-gui-interface";
348
+ ```
349
+
350
+ ---
351
+
352
+ ## Development
353
+
354
+ ```bash
355
+ npm run build # tsc → dist/
356
+ npm run build:watch
357
+ npm test # build, then node --test test/*.test.mjs
358
+ ```
359
+
360
+ ---
361
+
362
+ ## Documentation interne
363
+
364
+ Un **guide d’utilisation** (comment intégrer la lib dans un MCP + GUI) est maintenu dans **MCP Documentor**, projet **`mcp-gui-interface`**, entrée *Guide d’utilisation — @imenam/mcp-gui-interface* (dossier `guide`).
365
+
366
+ ---
367
+
368
+ ## Publication
369
+
370
+ Chaque push sur `master`, merge de pull request compris, publie une nouvelle version de `@imenam/mcp-gui-interface` sur npm (`.github/workflows/publish.yml`) : `npm ci`, build et tests, puis `npm version patch`, dont le commit et le tag sont poussés sur `master`, et `npm publish`. La publication passe par le trusted publishing de npm (OIDC, sans token), avec attestation de provenance. Un échec du build ou des tests arrête le workflow avant la montée de version.
371
+
372
+ Sur npmjs.com, le paquet déclare ce workflow comme publieur de confiance (Settings → Trusted publishing → GitHub Actions : `Matthieu-Pesnot-Pin`, `mcp-gui-interface`, `publish.yml`, avec `npm publish` dans les actions autorisées). Le commit de version est poussé avec `GITHUB_TOKEN` : si `master` est protégée, la règle doit autoriser GitHub Actions à y pousser.
373
+
374
+ ## License
375
+
376
+ ISC © [Matthieu Pesnot-Pin](https://github.com/Matthieu-Pesnot-Pin)
@@ -20,6 +20,7 @@
20
20
  * `trustedOrigins`. Requests without either header (curl, scripts) pass.
21
21
  */
22
22
  import os from "node:os";
23
+ import { createLogger } from "./logger.js";
23
24
  /** Interface every GUI worker binds to. */
24
25
  export const GUI_LISTEN_HOST = "127.0.0.1";
25
26
  const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "::1"];
@@ -48,6 +49,25 @@ function originHostname(origin) {
48
49
  return null;
49
50
  }
50
51
  }
52
+ const logger = createLogger("GuiRequestGuard");
53
+ /**
54
+ * Addresses of this machine's network interfaces. A sandbox that forbids
55
+ * netlink sockets (systemd `RestrictAddressFamilies` without `AF_NETLINK`)
56
+ * makes the listing fail: the addresses are then not trusted, and a warning
57
+ * says so.
58
+ */
59
+ function interfaceAddresses() {
60
+ let interfaces;
61
+ try {
62
+ interfaces = os.networkInterfaces();
63
+ }
64
+ catch (error) {
65
+ logger.warn(`Network interfaces cannot be listed (${error instanceof Error ? error.message : String(error)}): ` +
66
+ "origins using this machine's IP addresses are not trusted; list them in trustedOrigins if needed.");
67
+ return [];
68
+ }
69
+ return Object.values(interfaces).flatMap((addresses) => (addresses ?? []).map((address) => address.address.toLowerCase()));
70
+ }
51
71
  /**
52
72
  * Hostnames an `Origin` may carry: loopback names, this machine's hostname and
53
73
  * interface addresses, the proxy's hostname and those of `trustedOrigins`.
@@ -55,10 +75,8 @@ function originHostname(origin) {
55
75
  export function trustedOriginHostnames(options = {}) {
56
76
  const names = new Set(LOOPBACK_HOSTNAMES);
57
77
  names.add(os.hostname().toLowerCase());
58
- for (const addresses of Object.values(os.networkInterfaces())) {
59
- for (const address of addresses ?? [])
60
- names.add(address.address.toLowerCase());
61
- }
78
+ for (const address of interfaceAddresses())
79
+ names.add(address);
62
80
  const proxyUrl = options.proxyUrl ?? process.env.PROXY_URL;
63
81
  for (const origin of [proxyUrl, ...(options.trustedOrigins ?? [])]) {
64
82
  const hostname = origin ? originHostname(origin) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-gui-interface",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "Shared library for MCP GUI lifecycle management — spawning, IPC, proxy registration, logging, request guard",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -19,7 +19,6 @@
19
19
  "scripts": {
20
20
  "build": "tsc",
21
21
  "build:watch": "tsc --watch",
22
- "release": "npm version patch && npm run build && npm publish --access public && git push origin master --follow-tags",
23
22
  "test": "npm run build && node --test test/*.test.mjs"
24
23
  },
25
24
  "keywords": [
@@ -34,7 +33,7 @@
34
33
  ],
35
34
  "repository": {
36
35
  "type": "git",
37
- "url": "https://github.com/Matthieu-Pesnot-Pin/mcp-gui-interface.git"
36
+ "url": "git+https://github.com/Matthieu-Pesnot-Pin/mcp-gui-interface.git"
38
37
  },
39
38
  "author": "Matthieu Pesnot-Pin",
40
39
  "license": "ISC",