@imenam/mcp-gui-interface 1.0.12 → 1.0.13

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,50 @@ 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
+ 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
+
282
320
  ## Full-stack usage sketch
283
321
 
284
322
  Typical split:
285
323
 
286
324
  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.
325
+ 2. **GUI worker:** `setupLogging`, request guard, `ProxyClient.register`, bind HTTP server to returned port on `GUI_LISTEN_HOST`, `unregister` on shutdown.
288
326
 
289
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.
290
328
 
@@ -301,6 +339,9 @@ import type {
301
339
  ProxyConfig,
302
340
  Logger,
303
341
  SetupLoggingOptions,
342
+ GuiRequestGuardOptions,
343
+ GuiRequestInfo,
344
+ GuiRequestRejection,
304
345
  } from "@imenam/mcp-gui-interface";
305
346
  ```
306
347
 
@@ -311,6 +352,7 @@ import type {
311
352
  ```bash
312
353
  npm run build # tsc → dist/
313
354
  npm run build:watch
355
+ npm test # build, then node --test test/*.test.mjs
314
356
  npm run release # version patch, build, publish, push tags
315
357
  ```
316
358
 
@@ -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,129 @@
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
+ /** Interface every GUI worker binds to. */
24
+ export const GUI_LISTEN_HOST = "127.0.0.1";
25
+ const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "::1"];
26
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
27
+ const SAME_ORIGIN_FETCH_SITES = new Set(["same-origin", "none"]);
28
+ /** Lower-cased hostname of a `Host` header or URL authority, without port or IPv6 brackets. */
29
+ export function hostnameOf(authority) {
30
+ const value = authority?.trim();
31
+ if (!value)
32
+ return null;
33
+ try {
34
+ return new URL(`http://${value}`).hostname.replace(/^\[|\]$/g, "").toLowerCase();
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function originHostname(origin) {
41
+ try {
42
+ const url = new URL(origin);
43
+ if (url.protocol !== "http:" && url.protocol !== "https:")
44
+ return null;
45
+ return url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Hostnames an `Origin` may carry: loopback names, this machine's hostname and
53
+ * interface addresses, the proxy's hostname and those of `trustedOrigins`.
54
+ */
55
+ export function trustedOriginHostnames(options = {}) {
56
+ const names = new Set(LOOPBACK_HOSTNAMES);
57
+ 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
+ }
62
+ const proxyUrl = options.proxyUrl ?? process.env.PROXY_URL;
63
+ for (const origin of [proxyUrl, ...(options.trustedOrigins ?? [])]) {
64
+ const hostname = origin ? originHostname(origin) : null;
65
+ if (hostname)
66
+ names.add(hostname);
67
+ }
68
+ return names;
69
+ }
70
+ /** Returns why the request must be refused, or `null` when it is accepted. */
71
+ export function checkGuiRequest(request, trustedHostnames) {
72
+ const host = hostnameOf(request.host);
73
+ if (!host || !LOOPBACK_HOSTNAMES.includes(host)) {
74
+ return {
75
+ status: 403,
76
+ error: `Host "${request.host ?? ""}" is not allowed: open the GUI through the proxy or http://localhost.`,
77
+ };
78
+ }
79
+ if (SAFE_METHODS.has(request.method.toUpperCase()))
80
+ return null;
81
+ const fetchSite = request.secFetchSite?.trim().toLowerCase();
82
+ if (fetchSite) {
83
+ return SAME_ORIGIN_FETCH_SITES.has(fetchSite)
84
+ ? null
85
+ : { status: 403, error: `Cross-site request refused (Sec-Fetch-Site: ${fetchSite}).` };
86
+ }
87
+ if (request.origin === undefined)
88
+ return null;
89
+ const originHost = originHostname(request.origin);
90
+ if (!originHost || !trustedHostnames.has(originHost)) {
91
+ return { status: 403, error: `Cross-site request refused (Origin: ${request.origin}).` };
92
+ }
93
+ return null;
94
+ }
95
+ /** Hono middleware applying {@link checkGuiRequest}: `app.use("*", honoGuiGuard())`. */
96
+ export function honoGuiGuard(options = {}) {
97
+ const trusted = trustedOriginHostnames(options);
98
+ return async (c, next) => {
99
+ const rejection = checkGuiRequest({
100
+ method: c.req.method,
101
+ host: c.req.header("host"),
102
+ origin: c.req.header("origin"),
103
+ secFetchSite: c.req.header("sec-fetch-site"),
104
+ }, trusted);
105
+ if (rejection)
106
+ return c.json({ error: rejection.error }, rejection.status);
107
+ await next();
108
+ };
109
+ }
110
+ function singleHeader(value) {
111
+ return Array.isArray(value) ? value[0] : value;
112
+ }
113
+ /** Express middleware applying {@link checkGuiRequest}: `app.use(expressGuiGuard())`, before any route. */
114
+ export function expressGuiGuard(options = {}) {
115
+ const trusted = trustedOriginHostnames(options);
116
+ return (req, res, next) => {
117
+ const rejection = checkGuiRequest({
118
+ method: req.method,
119
+ host: singleHeader(req.headers.host),
120
+ origin: singleHeader(req.headers.origin),
121
+ secFetchSite: singleHeader(req.headers["sec-fetch-site"]),
122
+ }, trusted);
123
+ if (rejection) {
124
+ res.status(rejection.status).json({ error: rejection.error });
125
+ return;
126
+ }
127
+ next();
128
+ };
129
+ }
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.13",
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",