Require operator-declared hostnames before serving MCP HTTP outside loopback, preserving Host/Origin gates. Parse hostname identities once, share typed startup recovery across CLI/MCP, and announce an admitted client URL. Generated AgentEnv bundles declare their client/service names and validate DNS/HTTP name identity before export. Validated through real packaged HTTP requests, direct SDK connection, compiled export refusals, 184 focused tests, static and documentation checks on the refreshed main integration. Final CI and review evidence is recorded in the PR body. BREAKING CHANGE: non-loopback deployments require MCP_ALLOWED_HOSTS or rea mcp --http --allowed-hosts; bundle names must be unchanged HTTP hostnames within the 63-octet DNS label bound. Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
15 KiB
Verification
Verified base: cf4cc6faab0fb2c444ca054db8b98cb981bae6c9 (fetched main).
The worktree carries the manifest-view and streamable-HTTP increments as
commits on that base; this increment's changes are uncommitted edits on top.
Every check below ran as written on this tree in the review-response pass;
nothing is reported that did not run. Timings are macOS /usr/bin/time -l
command accounting (elapsed, user, system seconds and maximum resident set
size of the command and the children it waited for).
Review findings addressed
An adversarial review of the first pass found one should-fix and six nits; each was repaired at its owning boundary before the gates below ran.
POST /agentenvread its body withrequest.json()on the streamednode:httpbody, so a 60 MB payload was buffered before the-32700answer. The route now reads through the SDK'sreadRequestBodywithDEFAULT_MAX_REQUEST_BODY_SIZE, answering413with JSON-RPC-32000(id: null) before parsing, and parses within the bound through the domain'ssafeParseJson. The JSON-RPC error helper of the shared listener is exported asjsonRpcErrorResponseand reused rather than restated.- A set but blank
ENVIRONMENT_NAMErefuses withconfiguration_invalid, as a blankMCP_HOSTdoes in the sibling parser;docs/cli.mdanddocs/agent-environments.mdnow say so, and the spec records the divergence from the design's||reading as deliberate. - The record-coverage refusal in
loadEnvironmentSourcewas unreachable for authenticated pairs (the Evidence id is computed overnormalized_result, so a matching parent id implies identical records, each with a request line by construction of the projector). The branch is gone: cited records are indexed unconditionally, a record without a request line is left out and the tool refuses at call time through the existing no-record path. The claim was removed fromdocs/agent-environments.mdandspec.md, and the services test now asserts an empty manifest indexes nothing and a tampered capture is refused by authentication. - Every advertised input schema closes with
additionalProperties: false, so an unknown argument is refused as invalid arguments instead of silently degrading to an omitted one. The agentenv framework's card consumers (verify_env_card.py, the gateway'stool.inputSchemapass-through and the spec-conformance normaliser) readpropertiesandrequiredonly, so the extra key does not disturb them; the guide names it as a key the echo schema does not carry. - The tool description now reads
METHOD template; replays retained records [ordinals]; evidence ev_…, as the design text specifies. serveEnvironmentpasses anonerrorto the shared listener that logs{ error }aterrorlevel and forwards to the caller; the command races the cancellation signal against that failure and, on a listener failure after binding, closes the listener and returns the same typedresource_constrainterror the bind failure returns (exit1) instead of serving nothing. The two constructions share onetransportErrorhelper.tests/boundary/cli/cliStartupImports.test.tslistsdist/server/environment/serveEnvironment.jsasenvironment-serverinDEFERRED_IMPORTS, so the--helpprobe would fail if the serve command ever imported the listener eagerly.
Setup
npm ci exited 0 (540 packages added, 541 audited; 4.79 s elapsed, 3.46 s
user, 7.34 s sys, 880803840 bytes). After the edits above, npm run build
exited 0 (three turbo tasks; 4.61 s elapsed, 14.76 s user, 3.23 s sys,
3053060096 bytes) and npm run test:prepare exited 0 (six tasks; 1.28 s
elapsed, 2.94 s user, 0.67 s sys, 424935424 bytes), so every built-group run
and built-CLI check below used a dist/ carrying the revised serve command.
Static gates
Each exit 0 on the final tree: npm run check:fast (typecheck and lint,
2 tasks; 4.76 s elapsed, 23.36 s user, 2.56 s sys, 4458840064 bytes). npm run format (4064 files; 0.93 s elapsed, 4.12 s user, 0.76 s sys, 226344960
bytes) then npm run format:check (all 4064 files formatted; 0.64 s elapsed,
4.19 s user, 0.66 s sys, 219906048 bytes). npx knip (no findings; 3.49 s
elapsed, 4.92 s user, 0.63 s sys, 871645184 bytes). npm run check:ci:tasks
(8 tasks successful, 2 served from the turbo cache; lint:built 3756 files,
0 errors; jscpd 3164 files, 2583 clones, 3.34% duplicated lines under the
3.5% threshold; the stray-marker scan; 7.16 s elapsed, 26.46 s user, 5.88 s
sys, 2939142144 bytes). npm run verify:architecture-guards passed across
1523 source files and npm run verify:test-discovery discovered all 1592
test files exactly once. npm run docs:check (six tasks plus the README
consistency test, 5 cases; 3.37 s elapsed, 3.49 s user, 0.84 s sys,
291618816 bytes). After the last edit of this record, npm run format,
npm run format:check and npm run docs:check were rerun (each exit 0) and
the same static set was rerun uncached as npx turbo run typecheck lint:built format:check knip metadata:check jscpd scan:todos --force (8 tasks
successful, 0 cached, 3.34% duplication; 8.40 s elapsed, 48.82 s user, 7.77 s
sys, 4638474240 bytes). A case-insensitive grep for the forbidden words and
marker comments over all 28 new or modified files found nothing.
Test projects
Source group on the final tree: /usr/bin/time -l npx vitest run --project services --project adapters passed 343 files, 5196 tests with 8 skipped, in
99.55 s elapsed, 93.60 s user, 28.88 s sys, 1781055488 bytes. The increment's
files are environmentSource.test.ts (4 tests: pair authentication and
exchange indexing, parent mismatch as invalid_request with path
["evidence","evidence_id"], other view kind, non-capture evidence and
non-Evidence JSON, an empty manifest indexing nothing with a tampered capture
refused by authentication), environmentTools.test.ts (5 tests: ordered
mapping and the ; evidence description, required path parameters with rule,
observed values and additionalProperties: false, optional query strings
without a body for reads, a body object for writes, the card's key order),
environmentReplay.test.ts (7 tests, unchanged in this pass) and
serveEnvironment.test.ts (5 adapters tests: the card text byte-equal to the
echo card shape with the fixture tools, /agentenv answering -32601 with
-32700 for a malformed body, a route-level body of
DEFAULT_MAX_REQUEST_BODY_SIZE plus envelope answered 413 -32000 with
id: null, a full listener driven by the SDK streamable HTTP client with
_meta equal to TARGET_CONTENT_RESULT_META, exact and inexact replays, an
argument-less call and an unknown argument both refused with isError, and
the renamed override with an oversized body answered 413 through
node:http and a released port).
Focused built group after test:prepare: npx vitest run --project cli-boundary --project mcp-boundary --project process-boundary --project boundary over environmentServeCli, environmentServeMcp,
environmentServeShutdown, mcpHttpShutdown, streamableHttpMcp,
cliToolCoverage, cliStartupImports (now carrying the environment-server
deferred import), dispatcher, productCatalog, documentationFacts and
ciTestProjects passed 11 files, 33 tests (16.38 s elapsed, 25.77 s user,
5.94 s sys, 941948928 bytes).
Full built-group projects ran one after another on the final dist/ from a
background shell while the built-CLI exchange below ran in the foreground.
/usr/bin/time -l npx vitest run --project mcp-boundary passed 156 files
with 2 skipped, 914 tests with 7 skipped, in 166.23 s elapsed, 319.51 s user,
46.18 s sys, 4452171776 bytes. /usr/bin/time -l npx vitest run --project cli-boundary exited 1: 109 files passed and 3 failed, 663 tests passed,
2 skipped and 7 failed, in 432.45 s elapsed, 725.28 s user, 213.32 s sys,
641597440 bytes. No failing file belongs to this increment.
javascriptApplicationJsonOutput.test.ts (4 cases) and
javascriptApplicationProgress.test.ts (2 cases) failed with the worker's
result-capacity constraint ("The parent's result capacity was exhausted
after retaining static facts for main.js"); rerun together on the idle host
after the chain they passed 2 files, 17 tests (17.05 s elapsed, 26.40 s user,
8.47 s sys, 282394624 bytes), the load classification the previous pass
recorded. closedStdout.test.ts "stops quietly when the reader closes during
streamed JSON" exits 1 instead of 141 with 1009 bytes received, alone as
well as in the project, the environmental failure the previous pass
reproduced on a clean clone (macOS, Node 22.23.1, npm 12.0.2).
commandFinalizationCancellation.test.ts, which the previous pass saw fail
on worker order, passed in this run.
/usr/bin/time -l npx vitest run --project process-boundary exited 1: 56
files passed, 2 skipped and 3 failed, 463 tests passed, 19 skipped and 5
failed, in 431.71 s elapsed, 148.34 s user, 114.81 s sys, 710000640 bytes.
environmentServeShutdown.test.ts and mcpHttpShutdown.test.ts passed inside
it. jadxIntegration.test.ts failed three cases at the 30 s test and 10 s
hook timeouts ("retires an idle engine", "completes large class inventories",
"close cancels active and queued work"), the lane the previous pass recorded
as moving between runs on this host. releaseArtifactCanary.test.ts fails
parsing npm pack --json ("expected tuple, received object"), the npm 12.0.2
shape the previous pass reproduced on a clean clone.
processCaptureLifecycle.test.ts "keeps the process deadline bounded when the
wall clock moves backward" failed with expected false to be true on
result.ok; rerun alone on the idle host it failed the same way with 23
passed (38.12 s elapsed, 9.84 s user, 18.07 s sys, 323076096 bytes). The file
and src/process/ are untouched by this increment and the case mocks
Date.now around a real process capture with a 1.5 s fallback abort; it is
recorded as a host-dependent failure of that lane, not classified further
here.
Built-CLI exchange on the prototype capture
node scripts/rea.mjs inspect-web-network-capture grafana-like.har har --format json wrote capture Evidence ev_4ee4759b… with 28 records;
inspect-analysis-view with the inline source and all three template rules
wrote manifest Evidence ev_6553a8e5… naming that parent, grafana-example,
19 tools. environment-serve manifest.json --evidence capture.json --port 0 --json < /dev/null announced one stderr line
{"transport":"streamable-http","url":"http://127.0.0.1:62305/mcp","msg":"MCP transport listening"}
and nothing on stdout until shutdown.
GET /.well-known/agent-env.json returned a card whose top-level keys,
capabilities keys and tool keys equal the echo card's in order; its
inputSchema keys are type, properties, required and
additionalProperties, the echo card's three plus the closing key. Fixed
fields: protocolVersion 1.0, url /agentenv, preferredTransport
JSONRPC, additionalInterfaces [{url:/mcp,transport:mcp}], extensions
null, operations [], children_environments null, 19 tools, with
get_users_by_id described as GET /api/users/{numeric}; replays retained records [5, 6]; evidence ev_4ee4759b….
The Python mcp 1.30.0 client (httpx 0.28.1) initialised against
grafana-example 6.6.0 (protocol 2025-11-25), listed 19 tools, called
get_users_by_id {"id":"2"} and read body.login == "probe",
replay.exact_match == True, _meta.provenance.untrusted_content == True
with source == "analysis_target"; {"id":"999"} gave exact_match == False; get_folders_by_uid {"uid":"general"} gave recorded_path == "/api/folders/general"; get_search {"query":"probe","type":"dash-db"}
returned a list body whose first title is probe dashboard with
recorded_query == "?query=probe&type=dash-db"; get_users_by_id {"id":"2","unknown":"x"} returned isError == True with no structured
content.
POST /agentenv {"jsonrpc":"2.0","id":1,"method":"data/reset"} returned
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found: data/reset"}}.
A 60 MB non-JSON POST with a Content-Length header was answered 413
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Request body must not exceed 4194304 bytes"},"id":null}
after curl had sent 768332 bytes, in 1.4 ms, with the server's resident set
269024 KiB before and 269040 KiB after; the same file sent chunked without a
length was answered 413 after 5237311 bytes in 9.9 ms, resident set 267952
KiB before and 274208 KiB after, so the read stops at the bound on both
paths. A malformed body within the bound ({) was answered 400
{"jsonrpc":"2.0","error":{"code":-32700,"message":"parse error"},"id":null}.
SIGTERM ended the process with status 143 after one stdout document
{"status":"stopped","url":"http://127.0.0.1:62305/mcp","environment":"grafana-example","tools":19,"evidence_id":"ev_4ee4759b…","manifest_evidence_id":"ev_6553a8e5…"}.
A restart with ENVIRONMENT_NAME=renamed served a card named renamed,
reported "environment":"renamed" in its stopped document and also exited 143. ENVIRONMENT_NAME= (set, blank) exited 1 before binding with
{"code":"configuration_invalid","details":{"settings":[{"setting":"ENVIRONMENT_NAME",…}]}}.
The manifest served against the capture of one selected record (--record 0, ev_3249d233…) was refused with exit 1 and
{"code":"invalid_request","details":{"operation":"environment-serve","issues":[{"path":["evidence","evidence_id"],"reason":"invalid_value","expected":"ev_4ee4759b…",…}]}}
whose message names both identifiers; omitting --evidence exited 1 with
Incur's VALIDATION_ERROR naming evidence. No rea.mjs process or
listener remained after these runs.
Independent review
The adversarial review of the first pass is the independent review of this
increment; its findings and their repairs are listed above. One further
observation surfaced while repairing them: the byte-identical card assertion
in serveEnvironment.test.ts compares the inputSchema object exactly, so
closing the schema required extending that expectation as well as the
services tests, which is the intended coupling between the card and the
advertised MCP schema.
Scope and measurement limits
The echo-card comparison is exact on keys, nesting and tool entries; the
property names inside inputSchema.properties necessarily differ from the
echo tool's single text property, and inputSchema carries one key the echo
schema does not. exact_match is defined over supplied values only; omitted
parameters cannot mismatch, and a call without arguments is exact on the
tool's first record. Request bodies on write tools are accepted and ignored.
The post-bind listener error path is wired and type-checked but not
provoked by a test, since the shared listener does not expose its
node:http server. These recorded runs predate the required non-loopback Host/Origin allowlist;
the Python exchange, curl checks and tests recorded here used 127.0.0.1.
The current contract and later non-loopback coverage are described in the
transport specification.
Replay selection and body parsing were measured on the 28-record fixture
only; no large-capture timing was taken. Resident-set figures are ps samples
of the serving process before and after one request each. Token usage and
full process-family peak are unmeasured; the /usr/bin/time -l figures
account for each command and the children it waited for. Windows was not
run.