Problem
neo MCP HTTP/SSE servers (knowledge-base, memory-core) deployed behind a reverse proxy on a real public hostname reject every request with {"jsonrpc":"2.0","error":{"code":-32000,"message":"Invalid Host: <hostname>"},"id":null}.
Root cause: ai/mcp/server/shared/services/TransportService.mjs (~line 104) calls the MCP SDK's createMcpExpressApp() with no options. The SDK then defaults host='127.0.0.1' and installs localhostHostValidation(), allowing only localhost / 127.0.0.1 / [::1] as the Host header. A reverse proxy (Caddy v2 etc.) forwards the original public Host by default → not allowlisted → flat 403 before any MCP/JSON-RPC handling. It is client-agnostic: browser and MCP client are rejected identically.
Canonical gap: ai/deploy/Caddyfile only "works out of the box" because it defaults NEO_DEPLOY_HOSTNAME=localhost. Any real public hostname hits this wall. Surfaced by the first real-world public cloud deployment.
Fix
TransportService should pass a computed allowedHosts list into createMcpExpressApp({ allowedHosts }):
['localhost','127.0.0.1','[::1]'] ∪ (hostname of aiConfig.publicUrl, if set) ∪ (aiConfig.allowedHosts split on ',')
- Reuse existing: the
publicUrl leaf (NEO_PUBLIC_URL) already exists in all three templates — derive its hostname so a deployment that already sets NEO_PUBLIC_URL for OAuth resource-metadata gets host-allowlisting for free.
- New leaf + env: add an
allowedHosts leaf → NEO_MCP_ALLOWED_HOSTS (comma-separated) for multi-hostname deployments or where the client Host differs from publicUrl.
- Invariant:
localhost / 127.0.0.1 / [::1] are always included — the container healthcheck hits http://127.0.0.1:3000; dropping them breaks the healthcheck → restart loop.
Both servers (kb + mc)
The logic lives in the shared TransportService, so one code change covers both. The new allowedHosts leaf must be declared in both per-server templates and the base, since each declares its transport block independently:
ai/config.template.mjs
ai/mcp/server/knowledge-base/config.template.mjs
ai/mcp/server/memory-core/config.template.mjs
Contract Ledger
| Target Surface |
Source of Authority |
Behavior |
Fallback / Edge |
Evidence |
allowedHosts leaf / NEO_MCP_ALLOWED_HOSTS env |
this ticket |
comma-separated extra hostnames added to the MCP transport allowlist |
empty/unset → allowlist is just localhost-set + publicUrl host |
unit test on computed list |
createMcpExpressApp({allowedHosts}) in TransportService |
SDK host-header validation |
accepts the configured public Host; still rejects unknown hosts (DNS-rebinding protection retained) |
localhost-set ALWAYS present (healthcheck-safe) |
unit + deploy smoke |
publicUrl (NEO_PUBLIC_URL) |
existing leaf, all 3 templates |
its hostname is auto-added to the allowlist |
unset → only localhost-set + explicit NEO_MCP_ALLOWED_HOSTS |
unit test |
Acceptance Criteria
- With
NEO_PUBLIC_URL=https://mcp.example.com, a request with Host: mcp.example.com is accepted (no -32000).
- With
NEO_MCP_ALLOWED_HOSTS=a.example.com,b.example.com, Hosts a.example.com and b.example.com are accepted.
localhost / 127.0.0.1 / [::1] are accepted regardless of config (healthcheck-safe).
- Both knowledge-base and memory-core honor it (shared
TransportService + leaf in both per-server templates + base).
- A unit test covers the allowlist computation: localhost-always,
publicUrl-derived, NEO_MCP_ALLOWED_HOSTS-extra, and empty-config cases.
Refs #12367 (related deployment-hardening work, not a dependency).
Problem
neo MCP HTTP/SSE servers (knowledge-base, memory-core) deployed behind a reverse proxy on a real public hostname reject every request with
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Invalid Host: <hostname>"},"id":null}.Root cause:
ai/mcp/server/shared/services/TransportService.mjs(~line 104) calls the MCP SDK'screateMcpExpressApp()with no options. The SDK then defaultshost='127.0.0.1'and installslocalhostHostValidation(), allowing onlylocalhost/127.0.0.1/[::1]as theHostheader. A reverse proxy (Caddy v2 etc.) forwards the original publicHostby default → not allowlisted → flat 403 before any MCP/JSON-RPC handling. It is client-agnostic: browser and MCP client are rejected identically.Canonical gap:
ai/deploy/Caddyfileonly "works out of the box" because it defaultsNEO_DEPLOY_HOSTNAME=localhost. Any real public hostname hits this wall. Surfaced by the first real-world public cloud deployment.Fix
TransportServiceshould pass a computedallowedHostslist intocreateMcpExpressApp({ allowedHosts }):publicUrlleaf (NEO_PUBLIC_URL) already exists in all three templates — derive its hostname so a deployment that already setsNEO_PUBLIC_URLfor OAuth resource-metadata gets host-allowlisting for free.allowedHostsleaf →NEO_MCP_ALLOWED_HOSTS(comma-separated) for multi-hostname deployments or where the clientHostdiffers frompublicUrl.localhost/127.0.0.1/[::1]are always included — the container healthcheck hitshttp://127.0.0.1:3000; dropping them breaks the healthcheck → restart loop.Both servers (kb + mc)
The logic lives in the shared
TransportService, so one code change covers both. The newallowedHostsleaf must be declared in both per-server templates and the base, since each declares its transport block independently:ai/config.template.mjsai/mcp/server/knowledge-base/config.template.mjsai/mcp/server/memory-core/config.template.mjsContract Ledger
allowedHostsleaf /NEO_MCP_ALLOWED_HOSTSenvpublicUrlhostcreateMcpExpressApp({allowedHosts})inTransportServiceHost; still rejects unknown hosts (DNS-rebinding protection retained)publicUrl(NEO_PUBLIC_URL)NEO_MCP_ALLOWED_HOSTSAcceptance Criteria
NEO_PUBLIC_URL=https://mcp.example.com, a request withHost: mcp.example.comis accepted (no-32000).NEO_MCP_ALLOWED_HOSTS=a.example.com,b.example.com, Hostsa.example.comandb.example.comare accepted.localhost/127.0.0.1/[::1]are accepted regardless of config (healthcheck-safe).TransportService+ leaf in both per-server templates + base).publicUrl-derived,NEO_MCP_ALLOWED_HOSTS-extra, and empty-config cases.Refs #12367 (related deployment-hardening work, not a dependency).