@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 +15 -0
- package/README.md +44 -2
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/request-guard.d.ts +52 -0
- package/dist/src/request-guard.js +129 -0
- package/package.json +6 -4
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
|
|
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
|
|
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
|
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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.
|
|
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",
|