Skip to content

Commit d309029

Browse files
authored
feat(ai): configurable MCP transport host-allowlist for cloud deploy behind a proxy (#12371) (#12373)
* feat(ai): configurable MCP transport host-allowlist for cloud deploy behind a proxy (#12371) MCP HTTP/SSE servers (knowledge-base + memory-core) behind a reverse proxy on a real public hostname were rejected with `-32000 Invalid Host: <host>` for every request: TransportService called the SDK's `createMcpExpressApp()` with no options, so it defaulted to localhost-only DNS-rebinding protection. The public Host the proxy forwards was never allowlisted. `TransportService.computeAllowedHosts(aiConfig)` now builds the list passed to `createMcpExpressApp({allowedHosts})`: ['localhost','127.0.0.1','[::1]'] ∪ (publicUrl hostname) ∪ (NEO_MCP_ALLOWED_HOSTS, comma-split). - The localhost set is ALWAYS included — the container healthcheck hits `http://127.0.0.1:<port>`; dropping it would fail the healthcheck (restart loop). Localhost-only deployments are unchanged (the computed list equals the prior default), so zero regression. - Reuses the existing `NEO_PUBLIC_URL` leaf (derives its hostname). - New `allowedHosts` leaf -> `NEO_MCP_ALLOWED_HOSTS` (comma-separated) for multi-hostname or Host != publicUrl cases, declared in the base + both per-server templates (kb + mc each declare their transport block independently). Both servers share TransportService, so one logic change covers kb + mc. Tests: 16/16 TransportService unit specs pass (11 existing + 5 new computeAllowedHosts cases: localhost-always/healthcheck-safe, publicUrl-derived, NEO_MCP_ALLOWED_HOSTS-extra+trim, de-dup, unparseable-publicUrl-ignored). Resolves #12371 Co-Authored-By: neo-opus-4-7 <neo-opus-4-7@neomjs.com> * docs(ai): fix computeAllowedHosts JSDoc — instance method, not static (#12371)
1 parent af3a189 commit d309029

5 files changed

Lines changed: 103 additions & 2 deletions

File tree

ai/config.template.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ class Config extends BaseConfig {
6363
* @type {string|null}
6464
*/
6565
publicUrl: leaf(null, 'NEO_PUBLIC_URL', 'url'),
66+
/**
67+
* Comma-separated extra hostnames added to the MCP transport's Host-header allowlist
68+
* (the SDK's DNS-rebinding protection). localhost/127.0.0.1/[::1] and the `publicUrl`
69+
* hostname are always allowed; set this for multi-hostname deployments or where the
70+
* client `Host` differs from `publicUrl`. Empty/null → only the implicit localhost +
71+
* publicUrl hosts. Consumed by TransportService.computeAllowedHosts.
72+
* @type {string|null}
73+
*/
74+
allowedHosts: leaf(null, 'NEO_MCP_ALLOWED_HOSTS', 'string'),
6675
/**
6776
* Port the MCP server's HTTP/SSE transport listens on.
6877
* Sub-servers will typically override this with their own defaultPort.

ai/mcp/server/knowledge-base/config.template.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ class Config extends BaseConfig {
6565
* @type {string|null}
6666
*/
6767
publicUrl: leaf(null, 'NEO_PUBLIC_URL', 'url'),
68+
/**
69+
* Comma-separated extra hostnames added to the MCP transport's Host-header allowlist
70+
* (the SDK's DNS-rebinding protection). localhost/127.0.0.1/[::1] and the `publicUrl`
71+
* hostname are always allowed; set this for multi-hostname deployments or where the
72+
* client `Host` differs from `publicUrl`. Empty/null → only the implicit localhost +
73+
* publicUrl hosts. Consumed by TransportService.computeAllowedHosts.
74+
* @type {string|null}
75+
*/
76+
allowedHosts: leaf(null, 'NEO_MCP_ALLOWED_HOSTS', 'string'),
6877
/**
6978
* Optional Express middleware function for authentication (only used if transport is 'sse').
7079
* @type {Function|null}

ai/mcp/server/memory-core/config.template.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ class Config extends BaseConfig {
8282
* @type {string|null}
8383
*/
8484
publicUrl: leaf(null, 'NEO_PUBLIC_URL', 'url'),
85+
/**
86+
* Comma-separated extra hostnames added to the MCP transport's Host-header allowlist
87+
* (the SDK's DNS-rebinding protection). localhost/127.0.0.1/[::1] and the `publicUrl`
88+
* hostname are always allowed; set this for multi-hostname deployments or where the
89+
* client `Host` differs from `publicUrl`. Empty/null → only the implicit localhost +
90+
* publicUrl hosts. Consumed by TransportService.computeAllowedHosts.
91+
* @type {string|null}
92+
*/
93+
allowedHosts: leaf(null, 'NEO_MCP_ALLOWED_HOSTS', 'string'),
8594
/**
8695
* Optional Express middleware function for authentication (only used if transport is 'sse').
8796
* @type {Function|null}

ai/mcp/server/shared/services/TransportService.mjs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,41 @@ class TransportService extends Base {
8585
*/
8686
mcpServers = new Map()
8787

88+
/**
89+
* Computes the Host-header allowlist for the SDK's DNS-rebinding protection
90+
* (`createMcpExpressApp({allowedHosts})`). Pure + side-effect-free so it is unit-testable
91+
* (an instance method on the singleton, callable as `TransportService.computeAllowedHosts(...)`)
92+
* without a live transport. The localhost set is ALWAYS included — the container healthcheck hits
93+
* `http://127.0.0.1:<port>`, so dropping it would fail the healthcheck (restart loop). The
94+
* public hostname is derived from `aiConfig.publicUrl` (the existing `NEO_PUBLIC_URL` leaf) and
95+
* augmented by the comma-separated `aiConfig.allowedHosts` (`NEO_MCP_ALLOWED_HOSTS`) for
96+
* multi-hostname deployments or where the client `Host` differs from the advertised URL.
97+
* @param {Object} [aiConfig={}]
98+
* @param {String|null} [aiConfig.publicUrl]
99+
* @param {String|null} [aiConfig.allowedHosts] Comma-separated extra hostnames
100+
* @returns {String[]} De-duplicated allowlist (hostnames, port-agnostic; IPv6 in brackets)
101+
*/
102+
computeAllowedHosts(aiConfig={}) {
103+
const hosts = new Set(['localhost', '127.0.0.1', '[::1]']);
104+
105+
if (aiConfig.publicUrl) {
106+
try {
107+
hosts.add(new URL(aiConfig.publicUrl).hostname)
108+
} catch {
109+
// Unparseable publicUrl is ignored here; an explicit NEO_MCP_ALLOWED_HOSTS entry can still cover it.
110+
}
111+
}
112+
113+
if (aiConfig.allowedHosts) {
114+
for (const host of String(aiConfig.allowedHosts).split(',')) {
115+
const trimmed = host.trim();
116+
trimmed && hosts.add(trimmed)
117+
}
118+
}
119+
120+
return [...hosts]
121+
}
122+
88123
/**
89124
* Setups the SSE transport for an MCP server.
90125
* @param {Object} options
@@ -101,7 +136,11 @@ class TransportService extends Base {
101136
const { StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');
102137
const crypto = await import('crypto');
103138
const cors = await import('cors');
104-
const app = createMcpExpressApp();
139+
// Host-allowlist for the SDK's DNS-rebinding protection. Always includes localhost (the
140+
// container healthcheck hits 127.0.0.1) plus the publicUrl hostname + NEO_MCP_ALLOWED_HOSTS,
141+
// so a cloud deployment behind a reverse proxy on a public hostname is not rejected with
142+
// `-32000 Invalid Host`.
143+
const app = createMcpExpressApp({allowedHosts: this.computeAllowedHosts(aiConfig)});
105144

106145
this.app = app;
107146

test/playwright/unit/ai/mcp/server/shared/services/TransportService.spec.mjs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ test.describe('Neo.ai.mcp.server.shared.services.TransportService', () => {
9393
});
9494

9595
test('setup() awaits listener accept-state before resolving (#10932)', async () => {
96-
// Bind-race regression guard: prior to #10932, TransportService.setup() returned
96+
// Bind-race regression guard: prior to the listen-callback fix, TransportService.setup() returned
9797
// synchronously after `app.listen()` without awaiting the listen-callback. Under load
9898
// or fullyParallel test interleaving, the calling spec's subsequent `fetch()` could
9999
// race the bind and observe the response object lacking expected shape (e.g.,
@@ -306,4 +306,39 @@ test.describe('Neo.ai.mcp.server.shared.services.TransportService', () => {
306306
});
307307
});
308308

309+
test.describe('computeAllowedHosts host-allowlist (DNS-rebinding) — #12371', () => {
310+
let TransportService;
311+
312+
test.beforeAll(async () => {
313+
TransportService = (await import('../../../../../../../../ai/mcp/server/shared/services/TransportService.mjs')).default;
314+
});
315+
316+
test('always includes the localhost set (empty config) — healthcheck-safe', () => {
317+
expect(TransportService.computeAllowedHosts({})).toEqual(['localhost', '127.0.0.1', '[::1]']);
318+
});
319+
320+
test('derives the public hostname from publicUrl, keeping localhost', () => {
321+
const hosts = TransportService.computeAllowedHosts({publicUrl: 'https://mcp.example.com/knowledge-base'});
322+
expect(hosts).toContain('mcp.example.com');
323+
expect(hosts).toContain('localhost');
324+
expect(hosts).toContain('127.0.0.1');
325+
});
326+
327+
test('adds comma-separated NEO_MCP_ALLOWED_HOSTS entries, trimmed, dropping blanks', () => {
328+
const hosts = TransportService.computeAllowedHosts({allowedHosts: 'a.example.com, b.example.com ,, c.example.com'});
329+
expect(hosts).toEqual(expect.arrayContaining(['a.example.com', 'b.example.com', 'c.example.com']));
330+
expect(hosts).not.toContain('');
331+
expect(hosts).toContain('127.0.0.1');
332+
});
333+
334+
test('de-duplicates when publicUrl host also appears in the explicit list', () => {
335+
const hosts = TransportService.computeAllowedHosts({publicUrl: 'https://mcp.example.com', allowedHosts: 'mcp.example.com'});
336+
expect(hosts.filter(host => host === 'mcp.example.com')).toHaveLength(1);
337+
});
338+
339+
test('ignores an unparseable publicUrl without throwing', () => {
340+
expect(TransportService.computeAllowedHosts({publicUrl: 'not a url'})).toEqual(['localhost', '127.0.0.1', '[::1]']);
341+
});
342+
});
343+
309344
});

0 commit comments

Comments
 (0)