Part of #121.
Goal
Make the MCP server's HTTP transport honestly local-only in both documentation and enforcement: rewrite the README so it stops promising "hosted marketplace deployments", make the bind address configurable via COREZOID_BIND_ADDR, and add a fail-closed guard that refuses to bind to a non-loopback address unless the operator explicitly opts in to running without built-in authentication.
Context
README.md:170 currently says:
COREZOID_HTTP_PORT — Activate the Streamable HTTP transport on this port (e.g. 8080). When set the server listens for MCP over HTTP instead of stdio — intended for hosted marketplace deployments. Credentials must be pre-configured in ~/.corezoid/config.json; the browser OAuth login flow is not available in HTTP mode.
The implementation does not match that promise:
plugins/corezoid/mcp-server/main.go:258 hardcodes addr := "127.0.0.1:" + port — the server can only bind to loopback.
plugins/corezoid/mcp-server/mcp_http.go:184 (httpMCPEndpoint) has no Bearer-token verification, no auth middleware. The Authorization header is announced in the CORS allowlist at mcp_http.go:175 but read nowhere.
- OAuth credentials are loaded once at startup into a process-global
apiToken (main.go:94-107, mcp_http.go:124-131). All HTTP requests share the same token — there is no per-request or per-tenant resolution.
- The single "session" concept (
Mcp-Session-Id header) is not cryptographic and carries no credentials.
For a local single-user setup this is fine and even safe — loopback is not exposed to the network, and the operator explicitly configures their own credentials. It is however not a hosted deployment model, and the current README wording invites someone to put this behind a reverse proxy and expose it, which would hand out full Corezoid API access with no authentication.
An external audit flagged this as a P0 defect. This issue closes the gap by making the security model match the documentation, and installs a runtime guard so the mismatch cannot be re-introduced by env-var alone.
A full hosted mode (per-tenant credentials, Bearer middleware with WWW-Authenticate, OAuth protected-resource metadata, rate limiting, audit log) is deliberately out of scope and tracked separately in plugins/corezoid/mcp-server/HOSTED_TODO.md (created by this issue).
Scope
In scope:
-
Introduce env var COREZOID_BIND_ADDR in the HTTP mode startup path (main.go:255-258 region). Default: 127.0.0.1. Accepted values without opt-in: 127.0.0.1, ::1, localhost.
-
Fail-closed guard: if COREZOID_BIND_ADDR resolves to anything else, refuse to start unless COREZOID_ALLOW_UNAUTHENTICATED_REMOTE=yes-i-know-there-is-no-auth is also set. Log a fatal error explaining the risk and exit non-zero. Match on lowercased hostname; do not attempt DNS resolution.
-
Rewrite the COREZOID_HTTP_PORT row in the "Environment variables" table in README.md:170 (and any adjacent HTTP-mode prose). New wording template:
Activate the Streamable HTTP transport on this port. Bound to loopback (127.0.0.1) by default; override with COREZOID_BIND_ADDR. The endpoint has no built-in authentication — credentials are loaded once from ~/.corezoid/config.json and shared across all requests. Intended for local coding-agent integrations only. For hosted deployment, place behind a reverse proxy (Nginx / Caddy / Cloudflare Access) that terminates TLS, enforces authentication, and applies rate limiting — the MCP server does not replace those components.
-
Add a new COREZOID_BIND_ADDR row to the same env-var table with the same warning.
-
Create plugins/corezoid/mcp-server/HOSTED_TODO.md (short, ~40 lines) describing what a real hosted mode requires: fail-closed Bearer middleware with proper WWW-Authenticate challenge, per-tenant credential resolution, OAuth 2.0 Protected Resource Metadata endpoint, rate limiting, audit log, TLS. Explicitly mark it as a separate future initiative — this file is a placeholder, not a task list to execute now.
-
Tests:
TestHTTPBindGuard_Loopback — starting with COREZOID_HTTP_PORT=<free> and no COREZOID_BIND_ADDR binds to 127.0.0.1 (already the current behaviour, guard test).
TestHTTPBindGuard_ExplicitLoopback — COREZOID_BIND_ADDR=127.0.0.1 and COREZOID_BIND_ADDR=::1 and COREZOID_BIND_ADDR=localhost all succeed without opt-in.
TestHTTPBindGuard_NonLoopbackRefuses — COREZOID_BIND_ADDR=0.0.0.0 without opt-in causes the startup function to return an error (or, if the function calls log.Fatal, refactor into a return + wire-up so it is testable). Message must mention the opt-in env var and the phrase "no built-in authentication".
TestHTTPBindGuard_NonLoopbackWithOptIn — COREZOID_BIND_ADDR=0.0.0.0 with COREZOID_ALLOW_UNAUTHENTICATED_REMOTE=yes-i-know-there-is-no-auth succeeds.
-
Make the HTTP startup function return an error rather than calling log.Fatal directly, if it does not already. Callers in main.go translate to log.Fatal at the outermost layer as needed.
Out of scope:
- Adding any actual authentication to the HTTP endpoint (that is what
HOSTED_TODO.md is for).
- Per-request tenant resolution or per-user credentials.
- Rate limiting, audit log, Bearer middleware.
- Reading and validating the
Authorization header. Remove it from the CORS allowlist only if reviewers agree; safer to leave it announced but continue to ignore its value (documented in HOSTED_TODO).
- Any change to stdio transport.
Files / areas to touch
plugins/corezoid/mcp-server/main.go — read COREZOID_BIND_ADDR around line 255; run guard before calling into mcp_http.go.
plugins/corezoid/mcp-server/mcp_http.go — accept the address as a parameter; refactor Fatal to error return if needed for testability.
plugins/corezoid/mcp-server/mcp_http_test.go — new bind-guard tests.
plugins/corezoid/mcp-server/HOSTED_TODO.md — new file.
README.md — rewrite the COREZOID_HTTP_PORT row and add COREZOID_BIND_ADDR row in the env-var table (around line 170).
Acceptance criteria
Verification
cd plugins/corezoid/mcp-server
go build ./...
go vet ./...
go test -race -run TestHTTPBindGuard ./...
go test -race ./...
# Manual smoke tests (in three separate shells):
# 1. Default loopback:
COREZOID_HTTP_PORT=18080 go run . mcp-server &
curl -sS http://127.0.0.1:18080/mcp -X POST -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' >/dev/null
# → returns JSON-RPC response (existing behaviour)
# 2. Non-loopback refuses:
COREZOID_HTTP_PORT=18080 COREZOID_BIND_ADDR=0.0.0.0 go run . mcp-server
# → exits non-zero with the fail-closed message
# 3. Non-loopback with explicit opt-in:
COREZOID_HTTP_PORT=18080 COREZOID_BIND_ADDR=0.0.0.0 \
COREZOID_ALLOW_UNAUTHENTICATED_REMOTE=yes-i-know-there-is-no-auth \
go run . mcp-server
# → binds and serves on 0.0.0.0:18080
# Markdown link check:
cd ../../..
# Whatever tool the CI uses (typically markdown-link-check or lychee)
Dependencies
- Blocked by: None.
- Blocks: None.
- Related: None.
Notes
- The opt-in phrase
yes-i-know-there-is-no-auth is deliberately long and self-describing so it cannot be set by accident, cargo-culted from a tutorial, or invented by a package script without the operator reading it. Do not shorten it, do not accept aliases.
- IPv6 loopback (
::1) must be treated as safe alongside 127.0.0.1.
- Do NOT bind to
0.0.0.0 in tests — allocate a free port with net.Listen("tcp", "127.0.0.1:0") and pass the resolved port to the server. Bind-guard tests should exercise the guard function directly (return-error contract), not attempt an actual 0.0.0.0 bind.
- Do NOT attempt DNS resolution on
COREZOID_BIND_ADDR. Compare the raw string against the allowlist. Operators who want to bind to a specific interface by IP can pass the IP directly.
- If any published quick-start or install-script mentions
COREZOID_HTTP_PORT for a "public server" scenario, patch or remove it as part of this PR.
Part of #121.
Goal
Make the MCP server's HTTP transport honestly local-only in both documentation and enforcement: rewrite the README so it stops promising "hosted marketplace deployments", make the bind address configurable via
COREZOID_BIND_ADDR, and add a fail-closed guard that refuses to bind to a non-loopback address unless the operator explicitly opts in to running without built-in authentication.Context
README.md:170currently says:The implementation does not match that promise:
plugins/corezoid/mcp-server/main.go:258hardcodesaddr := "127.0.0.1:" + port— the server can only bind to loopback.plugins/corezoid/mcp-server/mcp_http.go:184(httpMCPEndpoint) has no Bearer-token verification, no auth middleware. TheAuthorizationheader is announced in the CORS allowlist atmcp_http.go:175but read nowhere.apiToken(main.go:94-107,mcp_http.go:124-131). All HTTP requests share the same token — there is no per-request or per-tenant resolution.Mcp-Session-Idheader) is not cryptographic and carries no credentials.For a local single-user setup this is fine and even safe — loopback is not exposed to the network, and the operator explicitly configures their own credentials. It is however not a hosted deployment model, and the current README wording invites someone to put this behind a reverse proxy and expose it, which would hand out full Corezoid API access with no authentication.
An external audit flagged this as a P0 defect. This issue closes the gap by making the security model match the documentation, and installs a runtime guard so the mismatch cannot be re-introduced by env-var alone.
A full hosted mode (per-tenant credentials, Bearer middleware with
WWW-Authenticate, OAuth protected-resource metadata, rate limiting, audit log) is deliberately out of scope and tracked separately inplugins/corezoid/mcp-server/HOSTED_TODO.md(created by this issue).Scope
In scope:
Introduce env var
COREZOID_BIND_ADDRin the HTTP mode startup path (main.go:255-258region). Default:127.0.0.1. Accepted values without opt-in:127.0.0.1,::1,localhost.Fail-closed guard: if
COREZOID_BIND_ADDRresolves to anything else, refuse to start unlessCOREZOID_ALLOW_UNAUTHENTICATED_REMOTE=yes-i-know-there-is-no-authis also set. Log a fatal error explaining the risk and exit non-zero. Match on lowercased hostname; do not attempt DNS resolution.Rewrite the
COREZOID_HTTP_PORTrow in the "Environment variables" table inREADME.md:170(and any adjacent HTTP-mode prose). New wording template:Add a new
COREZOID_BIND_ADDRrow to the same env-var table with the same warning.Create
plugins/corezoid/mcp-server/HOSTED_TODO.md(short, ~40 lines) describing what a real hosted mode requires: fail-closed Bearer middleware with properWWW-Authenticatechallenge, per-tenant credential resolution, OAuth 2.0 Protected Resource Metadata endpoint, rate limiting, audit log, TLS. Explicitly mark it as a separate future initiative — this file is a placeholder, not a task list to execute now.Tests:
TestHTTPBindGuard_Loopback— starting withCOREZOID_HTTP_PORT=<free>and noCOREZOID_BIND_ADDRbinds to127.0.0.1(already the current behaviour, guard test).TestHTTPBindGuard_ExplicitLoopback—COREZOID_BIND_ADDR=127.0.0.1andCOREZOID_BIND_ADDR=::1andCOREZOID_BIND_ADDR=localhostall succeed without opt-in.TestHTTPBindGuard_NonLoopbackRefuses—COREZOID_BIND_ADDR=0.0.0.0without opt-in causes the startup function to return an error (or, if the function callslog.Fatal, refactor into a return + wire-up so it is testable). Message must mention the opt-in env var and the phrase "no built-in authentication".TestHTTPBindGuard_NonLoopbackWithOptIn—COREZOID_BIND_ADDR=0.0.0.0withCOREZOID_ALLOW_UNAUTHENTICATED_REMOTE=yes-i-know-there-is-no-authsucceeds.Make the HTTP startup function return an error rather than calling
log.Fataldirectly, if it does not already. Callers inmain.gotranslate tolog.Fatalat the outermost layer as needed.Out of scope:
HOSTED_TODO.mdis for).Authorizationheader. Remove it from the CORS allowlist only if reviewers agree; safer to leave it announced but continue to ignore its value (documented in HOSTED_TODO).Files / areas to touch
plugins/corezoid/mcp-server/main.go— readCOREZOID_BIND_ADDRaround line 255; run guard before calling intomcp_http.go.plugins/corezoid/mcp-server/mcp_http.go— accept the address as a parameter; refactorFatalto error return if needed for testability.plugins/corezoid/mcp-server/mcp_http_test.go— new bind-guard tests.plugins/corezoid/mcp-server/HOSTED_TODO.md— new file.README.md— rewrite theCOREZOID_HTTP_PORTrow and addCOREZOID_BIND_ADDRrow in the env-var table (around line 170).Acceptance criteria
README.md:170no longer contains the phrase "hosted marketplace deployments" or any language implying HTTP mode is a public-facing / multi-user transport.COREZOID_BIND_ADDRwith default127.0.0.1and the auth caveat.COREZOID_HTTP_PORT=<port>set and no other overrides, the server binds to127.0.0.1:<port>(unchanged behaviour).COREZOID_BIND_ADDR=0.0.0.0set and no opt-in env, the server refuses to start with a non-zero exit code and a message mentioning the opt-in and "no built-in authentication".COREZOID_BIND_ADDR=0.0.0.0 COREZOID_ALLOW_UNAUTHENTICATED_REMOTE=yes-i-know-there-is-no-authset, the server starts and binds to0.0.0.0:<port>.plugins/corezoid/mcp-server/HOSTED_TODO.mdexists and describes the roadmap for real hosted mode.go test -race ./.....github/workflows/ci.yml) still passes after README edits.Verification
Dependencies
Notes
yes-i-know-there-is-no-authis deliberately long and self-describing so it cannot be set by accident, cargo-culted from a tutorial, or invented by a package script without the operator reading it. Do not shorten it, do not accept aliases.::1) must be treated as safe alongside127.0.0.1.0.0.0.0in tests — allocate a free port withnet.Listen("tcp", "127.0.0.1:0")and pass the resolved port to the server. Bind-guard tests should exercise the guard function directly (return-error contract), not attempt an actual0.0.0.0bind.COREZOID_BIND_ADDR. Compare the raw string against the allowlist. Operators who want to bind to a specific interface by IP can pass the IP directly.COREZOID_HTTP_PORTfor a "public server" scenario, patch or remove it as part of this PR.