@imenam/mcp-gui-interface 1.0.12 → 1.0.14

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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Matthieu Pesnot-Pin
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @imenam/mcp-gui-interface
2
2
 
3
- Shared library for MCP GUI lifecycle management — spawning, IPC, proxy registration, and logging.
3
+ Shared library for MCP GUI lifecycle management — spawning, IPC, proxy registration, logging, and network exposure rules.
4
4
 
5
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
6
 
@@ -279,12 +279,52 @@ logger.info("Server started");
279
279
 
280
280
  ---
281
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
+
282
322
  ## Full-stack usage sketch
283
323
 
284
324
  Typical split:
285
325
 
286
326
  1. **Master:** `setupLogging`, optional `GuiLauncher` + `start()` when `PROXY_URL` (or your policy) allows.
287
- 2. **GUI worker:** `setupLogging`, `ProxyClient.register`, bind HTTP server to returned port, `unregister` on shutdown.
327
+ 2. **GUI worker:** `setupLogging`, request guard, `ProxyClient.register`, bind HTTP server to returned port on `GUI_LISTEN_HOST`, `unregister` on shutdown.
288
328
 
289
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.
290
330
 
@@ -301,6 +341,9 @@ import type {
301
341
  ProxyConfig,
302
342
  Logger,
303
343
  SetupLoggingOptions,
344
+ GuiRequestGuardOptions,
345
+ GuiRequestInfo,
346
+ GuiRequestRejection,
304
347
  } from "@imenam/mcp-gui-interface";
305
348
  ```
306
349
 
@@ -311,6 +354,7 @@ import type {
311
354
  ```bash
312
355
  npm run build # tsc → dist/
313
356
  npm run build:watch
357
+ npm test # build, then node --test test/*.test.mjs
314
358
  npm run release # version patch, build, publish, push tags
315
359
  ```
316
360
 
@@ -2,5 +2,6 @@ export { GuiLauncher, reconnectGuiToolDefinition, type GuiLauncherOptions, } fro
2
2
  export { ProxyClient, type RegisterOptions, type RegisterResult } from "./proxy-client.js";
3
3
  export { APP_GROUP_ENV_VAR, APP_NAME_ENV_VARS, APP_PATH_ENV_VARS, APP_PATH_PREFIX_ENV_VAR, applyAppPathPrefix, normalizeAppPath, normalizeAppPathPrefix, resolveAppGroup, resolveAppName, resolveAppPath, resolveAppPathDetailed, type ResolvedAppPath, } from "./app-config.js";
4
4
  export { IpcHub, type IpcMessage } from "./ipc-hub.js";
5
+ export { GUI_LISTEN_HOST, checkGuiRequest, expressGuiGuard, honoGuiGuard, hostnameOf, trustedOriginHostnames, type GuiRequestGuardOptions, type GuiRequestInfo, type GuiRequestRejection, } from "./request-guard.js";
5
6
  export { setupLogging, createLogger } from "./logger.js";
6
7
  export type { ProxyConfig, Logger, SetupLoggingOptions, GuiRuntimeStatus, ReconnectResult, McpToolResult, } from "./types.js";
package/dist/src/index.js CHANGED
@@ -2,4 +2,5 @@ export { GuiLauncher, reconnectGuiToolDefinition, } from "./gui-launcher.js";
2
2
  export { ProxyClient } from "./proxy-client.js";
3
3
  export { APP_GROUP_ENV_VAR, APP_NAME_ENV_VARS, APP_PATH_ENV_VARS, APP_PATH_PREFIX_ENV_VAR, applyAppPathPrefix, normalizeAppPath, normalizeAppPathPrefix, resolveAppGroup, resolveAppName, resolveAppPath, resolveAppPathDetailed, } from "./app-config.js";
4
4
  export { IpcHub } from "./ipc-hub.js";
5
+ export { GUI_LISTEN_HOST, checkGuiRequest, expressGuiGuard, honoGuiGuard, hostnameOf, trustedOriginHostnames, } from "./request-guard.js";
5
6
  export { setupLogging, createLogger } from "./logger.js";
@@ -0,0 +1,52 @@
1
+ /** Interface every GUI worker binds to. */
2
+ export declare const GUI_LISTEN_HOST = "127.0.0.1";
3
+ /** The request headers {@link checkGuiRequest} looks at. */
4
+ export interface GuiRequestInfo {
5
+ method: string;
6
+ host: string | undefined;
7
+ origin: string | undefined;
8
+ secFetchSite: string | undefined;
9
+ }
10
+ /** Why a request is refused, with the HTTP status to answer. */
11
+ export interface GuiRequestRejection {
12
+ status: 403;
13
+ error: string;
14
+ }
15
+ export interface GuiRequestGuardOptions {
16
+ /** Proxy the GUI registers with; its hostname is trusted as an origin. Defaults to `process.env.PROXY_URL`. */
17
+ proxyUrl?: string;
18
+ /** Additional origins (e.g. `https://mcp.example.com`) whose hostname is trusted. */
19
+ trustedOrigins?: string[];
20
+ }
21
+ /** Lower-cased hostname of a `Host` header or URL authority, without port or IPv6 brackets. */
22
+ export declare function hostnameOf(authority: string | undefined): string | null;
23
+ /**
24
+ * Hostnames an `Origin` may carry: loopback names, this machine's hostname and
25
+ * interface addresses, the proxy's hostname and those of `trustedOrigins`.
26
+ */
27
+ export declare function trustedOriginHostnames(options?: GuiRequestGuardOptions): Set<string>;
28
+ /** Returns why the request must be refused, or `null` when it is accepted. */
29
+ export declare function checkGuiRequest(request: GuiRequestInfo, trustedHostnames: ReadonlySet<string>): GuiRequestRejection | null;
30
+ /** Minimal shape of a Hono context used by {@link honoGuiGuard}. */
31
+ interface HonoLikeContext {
32
+ req: {
33
+ method: string;
34
+ header(name: string): string | undefined;
35
+ };
36
+ json(body: unknown, status: 403): Response;
37
+ }
38
+ /** Hono middleware applying {@link checkGuiRequest}: `app.use("*", honoGuiGuard())`. */
39
+ export declare function honoGuiGuard(options?: GuiRequestGuardOptions): (c: HonoLikeContext, next: () => Promise<void>) => Promise<Response | void>;
40
+ /** Minimal shapes of the Express request and response used by {@link expressGuiGuard}. */
41
+ interface ExpressLikeRequest {
42
+ method: string;
43
+ headers: Record<string, string | string[] | undefined>;
44
+ }
45
+ interface ExpressLikeResponse {
46
+ status(code: number): {
47
+ json(body: unknown): unknown;
48
+ };
49
+ }
50
+ /** Express middleware applying {@link checkGuiRequest}: `app.use(expressGuiGuard())`, before any route. */
51
+ export declare function expressGuiGuard(options?: GuiRequestGuardOptions): (req: ExpressLikeRequest, res: ExpressLikeResponse, next: () => void) => void;
52
+ export {};
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Network exposure rules for a GUI worker's HTTP server.
3
+ *
4
+ * A GUI worker listens on {@link GUI_LISTEN_HOST} only: other machines reach
5
+ * it through the proxy or the gateway manager, which both relay to
6
+ * `localhost:<port>` / `127.0.0.1:<port>` and rewrite the `Host` header
7
+ * accordingly. The browser's `Origin` and `Sec-Fetch-Site` headers go through
8
+ * unchanged.
9
+ *
10
+ * {@link checkGuiRequest} enforces two rules:
11
+ *
12
+ * 1. **Host** — every request must carry a loopback `Host` (`localhost`,
13
+ * `127.0.0.1`, `::1`). A page served from a hostile domain that resolves to
14
+ * 127.0.0.1 (DNS rebinding) sends its own domain as `Host` and is refused.
15
+ * 2. **Cross-site modifications** — a request with a method other than GET,
16
+ * HEAD or OPTIONS is refused when the browser says it comes from another
17
+ * site: `Sec-Fetch-Site` other than `same-origin` / `none` when the header
18
+ * is present, otherwise an `Origin` whose hostname is neither a name or
19
+ * address of this machine, nor the proxy's, nor listed in
20
+ * `trustedOrigins`. Requests without either header (curl, scripts) pass.
21
+ */
22
+ import os from "node:os";
23
+ import { createLogger } from "./logger.js";
24
+ /** Interface every GUI worker binds to. */
25
+ export const GUI_LISTEN_HOST = "127.0.0.1";
26
+ const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "::1"];
27
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
28
+ const SAME_ORIGIN_FETCH_SITES = new Set(["same-origin", "none"]);
29
+ /** Lower-cased hostname of a `Host` header or URL authority, without port or IPv6 brackets. */
30
+ export function hostnameOf(authority) {
31
+ const value = authority?.trim();
32
+ if (!value)
33
+ return null;
34
+ try {
35
+ return new URL(`http://${value}`).hostname.replace(/^\[|\]$/g, "").toLowerCase();
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function originHostname(origin) {
42
+ try {
43
+ const url = new URL(origin);
44
+ if (url.protocol !== "http:" && url.protocol !== "https:")
45
+ return null;
46
+ return url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
47
+ }
48
+ catch {
49
+ return null;
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
+ }
71
+ /**
72
+ * Hostnames an `Origin` may carry: loopback names, this machine's hostname and
73
+ * interface addresses, the proxy's hostname and those of `trustedOrigins`.
74
+ */
75
+ export function trustedOriginHostnames(options = {}) {
76
+ const names = new Set(LOOPBACK_HOSTNAMES);
77
+ names.add(os.hostname().toLowerCase());
78
+ for (const address of interfaceAddresses())
79
+ names.add(address);
80
+ const proxyUrl = options.proxyUrl ?? process.env.PROXY_URL;
81
+ for (const origin of [proxyUrl, ...(options.trustedOrigins ?? [])]) {
82
+ const hostname = origin ? originHostname(origin) : null;
83
+ if (hostname)
84
+ names.add(hostname);
85
+ }
86
+ return names;
87
+ }
88
+ /** Returns why the request must be refused, or `null` when it is accepted. */
89
+ export function checkGuiRequest(request, trustedHostnames) {
90
+ const host = hostnameOf(request.host);
91
+ if (!host || !LOOPBACK_HOSTNAMES.includes(host)) {
92
+ return {
93
+ status: 403,
94
+ error: `Host "${request.host ?? ""}" is not allowed: open the GUI through the proxy or http://localhost.`,
95
+ };
96
+ }
97
+ if (SAFE_METHODS.has(request.method.toUpperCase()))
98
+ return null;
99
+ const fetchSite = request.secFetchSite?.trim().toLowerCase();
100
+ if (fetchSite) {
101
+ return SAME_ORIGIN_FETCH_SITES.has(fetchSite)
102
+ ? null
103
+ : { status: 403, error: `Cross-site request refused (Sec-Fetch-Site: ${fetchSite}).` };
104
+ }
105
+ if (request.origin === undefined)
106
+ return null;
107
+ const originHost = originHostname(request.origin);
108
+ if (!originHost || !trustedHostnames.has(originHost)) {
109
+ return { status: 403, error: `Cross-site request refused (Origin: ${request.origin}).` };
110
+ }
111
+ return null;
112
+ }
113
+ /** Hono middleware applying {@link checkGuiRequest}: `app.use("*", honoGuiGuard())`. */
114
+ export function honoGuiGuard(options = {}) {
115
+ const trusted = trustedOriginHostnames(options);
116
+ return async (c, next) => {
117
+ const rejection = checkGuiRequest({
118
+ method: c.req.method,
119
+ host: c.req.header("host"),
120
+ origin: c.req.header("origin"),
121
+ secFetchSite: c.req.header("sec-fetch-site"),
122
+ }, trusted);
123
+ if (rejection)
124
+ return c.json({ error: rejection.error }, rejection.status);
125
+ await next();
126
+ };
127
+ }
128
+ function singleHeader(value) {
129
+ return Array.isArray(value) ? value[0] : value;
130
+ }
131
+ /** Express middleware applying {@link checkGuiRequest}: `app.use(expressGuiGuard())`, before any route. */
132
+ export function expressGuiGuard(options = {}) {
133
+ const trusted = trustedOriginHostnames(options);
134
+ return (req, res, next) => {
135
+ const rejection = checkGuiRequest({
136
+ method: req.method,
137
+ host: singleHeader(req.headers.host),
138
+ origin: singleHeader(req.headers.origin),
139
+ secFetchSite: singleHeader(req.headers["sec-fetch-site"]),
140
+ }, trusted);
141
+ if (rejection) {
142
+ res.status(rejection.status).json({ error: rejection.error });
143
+ return;
144
+ }
145
+ next();
146
+ };
147
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@imenam/mcp-gui-interface",
3
- "version": "1.0.12",
4
- "description": "Shared library for MCP GUI lifecycle management — spawning, IPC, proxy registration, logging",
3
+ "version": "1.0.14",
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",
7
7
  "types": "./dist/src/index.d.ts",
@@ -13,12 +13,14 @@
13
13
  },
14
14
  "files": [
15
15
  "dist",
16
- "README.md"
16
+ "README.md",
17
+ "LICENSE"
17
18
  ],
18
19
  "scripts": {
19
20
  "build": "tsc",
20
21
  "build:watch": "tsc --watch",
21
- "release": "npm version patch && npm run build && npm publish --access public && git push origin master --follow-tags"
22
+ "release": "npm version patch && npm run build && npm publish --access public && git push origin master --follow-tags",
23
+ "test": "npm run build && node --test test/*.test.mjs"
22
24
  },
23
25
  "keywords": [
24
26
  "mcp",
@@ -32,7 +34,7 @@
32
34
  ],
33
35
  "repository": {
34
36
  "type": "git",
35
- "url": "https://github.com/Matthieu-Pesnot-Pin/mcp-gui-interface.git"
37
+ "url": "git+https://github.com/Matthieu-Pesnot-Pin/mcp-gui-interface.git"
36
38
  },
37
39
  "author": "Matthieu Pesnot-Pin",
38
40
  "license": "ISC",