Releases: NVNM-Chain/nvnm-mcp-server
Release list
v1.0.0-rc18
Added
anchor_get_registriessupports filtering by name: passnameand an
optionalmatch(exact(default),prefix,suffix, orcontains, all
case-insensitive). Since the anchoring precompile has no by-name index,
this scans the registry table client-side, finds every match rather
than the first, and returns theoffset/limitwindow of those matches
(defaulting, as any listing does, to offset 0 and 100 rows) with
pagination.totalset to the full match count -- so no match is hidden and
a caller can page through all of them. Registry names are caller-supplied
and not unique, so callers should checkcreator/created_atto
disambiguate.anchor_get_registry(id)stays ID-only and single-valued;
the by-name path lives only onanchor_get_registries. (W3)
Changed
namecan be combined withoffset/limiton
anchor_get_registries(they page the match set); combining it with
registry_idremains rejected, andErrInvalidFilterCombinationnow
describes that narrower rule.offset/limitstay optional, with the page
size defaulting to 100.
Deprecated
anchor_get_registries'registry_idparameter. It still returns that
single registry and still rejects every listing parameter
(name,match, non-zerooffset/limit), butanchor_get_registryis
the supported way to fetch one registry by ID.
Fixed
anchor_get_registriesinline doc corrections: thelimitfield's
schema description no longer claims chain-side pages are capped at 200 rows
for the caller's limit -- the caller's limit is applied client-side in all
listing paths and is never sent to the chain. The tool description now
explicitly states that every listing mode (filtered and unfiltered) does a
full client-side scan before windowing, not only the name-filtered path.
ThedefaultRegistriesPageSizeconstant comment no longer implies the
default may be applied further down in the client -- both listing branches
resolve pagination throughresolveRegistriesPage.anchor_prepare_update_record_statusandanchor_prepare_revoke_role
are now reachable on keyless (anonymous-read) deployments, classified
auth-exempt alongside their sibling prepare tools. (W1)- Auth-exemption coverage is now enforced by construction: a test walks
the live registered tool list and asserts every tool is either
auth-exempt or the one conditional write tool, failing and naming any
tool that's missing a classification, instead of pinning independent
size/count literals that could drift out of sync with each other. (W2) anchor_get_registries'next_actionshint now accurately describes
anchor_get_registryas ID-only, and separately points callers who
want a name-based lookup atanchor_get_registries(name=...). (W4)matchwithoutnameis rejected with a curated input error instead
of being silently ignored -- silently dropping a supplied parameter would
leave the caller believing a filter was applied when none was.- The by-name scan's pagination trusts
NextKeyemptiness alone as its
end-of-table signal (previously it also stopped on a short page, which
would silently end the walk after one page if the chain's own page cap
ever dropped below the client's requested page size). Same fix applied to
the seed script's and integration helper's walks. - Each by-name scan emits a structured log line (duration, match count,
requested offset/limit, truncated flag) so operators can watch the
client-side scan's frequency and cost. Note that paging a match set does
not reduce that cost: each page re-runs the full scan. The scan is a
stopgap: if the chain gains a by-name index as expected, or if indexing is
solved off-chain, it can be retired (#79).
Verifying signatures
Each binary is shipped with a Cosign keyless signature (via Sigstore OIDC) and a SHA-256 checksum.
# Verify checksum
shasum -a 256 -c nvnm-mcp-server-v1.0.0-rc18-<os>-<arch>.sha256
# Verify Cosign signature
cosign verify-blob \
--certificate nvnm-mcp-server-v1.0.0-rc18-<os>-<arch>.cert.pem \
--signature nvnm-mcp-server-v1.0.0-rc18-<os>-<arch>.sig \
--certificate-identity-regexp 'https://github.com/NVNM-Chain/nvnm-mcp-server/.*' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
nvnm-mcp-server-v1.0.0-rc18-<os>-<arch>v1.0.0-rc17
No CHANGELOG.md entry for 1.0.0-rc17; using auto-generated notes.
Verifying signatures
Each binary is shipped with a Cosign keyless signature (via Sigstore OIDC) and a SHA-256 checksum.
# Verify checksum
shasum -a 256 -c nvnm-mcp-server-v1.0.0-rc17-<os>-<arch>.sha256
# Verify Cosign signature
cosign verify-blob \
--certificate nvnm-mcp-server-v1.0.0-rc17-<os>-<arch>.cert.pem \
--signature nvnm-mcp-server-v1.0.0-rc17-<os>-<arch>.sig \
--certificate-identity-regexp 'https://github.com/NVNM-Chain/nvnm-mcp-server/.*' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
nvnm-mcp-server-v1.0.0-rc17-<os>-<arch>v1.0.0-rc16
Fixed
evm_get_logsover-wide block ranges now return an actionable message
("block range too wide … narrow the range and retry") instead of the opaque
upstream operation failed. The upstream node's range-cap rejection
(observed asmaximum [from, to] blocks distance: 10000) is recognized at
the RPC boundary via the same curated-allowlist pattern as precompile
reverts; the node's raw error text is never echoed. (dmome21 smoke
2026-07-28, Finding 3)evm_get_transactionnot-found responses no longer double the message
(transaction not found: transaction not found): the handler re-wrapped the
client's own sentinel with the same prefix, which also mislabeled genuine
upstream failures as not-found. Theevm_get_logshandler had the same
latent re-prefix and was fixed alongside. (Finding 4)- Empty-JSON-object metadata (
{}) rejection no longer leaks its
classifier (the trailing: missing required parameter): it now surfaces
a dedicated input-class sentinel whose text is exactly the curated message —
the value is present but invalid, not missing. (Finding 4)
Changed
- Bumped
actions/checkoutv6.0.3 → v7.0.1 across all workflows,
superseding Dependabot PR #60 (whose SHA update left the stale# v6
comments on the pins). v7's breaking change only affects
pull_request_target/workflow_runtriggers, which no workflow here uses.
CI-only, no image change. - Bumped the OpenTelemetry module family v1.43.0 → v1.44.0 (Prometheus
exporter v0.65.0 → v0.66.0) andmodelcontextprotocol/go-sdkv1.6.0 →
v1.6.1 in one batch, superseding Dependabot PRs #4–#13. Transitive lifts:
grpc-ecosystem/grpc-gateway/v2v2.28.0 → v2.29.0,genprotopins, and the
newgo.opentelemetry.io/otel/metric/xv0.66.0 module (all Apache-2.0 /
BSD-3). Routine version-update hygiene, no security advisories involved;
vendor tree regenerated. - Grouped future Dependabot OpenTelemetry updates into a single PR
(.github/dependabot.ymlgroups:stanza) so the
otel module family no longer arrives as 8+ interlocking PRs that conflict on
go.sum/vendor/. - Dependabot bumps merged since rc15 (recorded here for image
provenance):pressly/goose/v33.27.1 → 3.27.3 (#62),
prometheus/client_golang1.23.2 → 1.24.0 (#61), and the
distroless/static-debian12base-image digest refresh (#58) — all three
change the compiled image;docker/login-action4.2.0 → 4.5.0 (#59) is
CI-only.
Verifying signatures
Each binary is shipped with a Cosign keyless signature (via Sigstore OIDC) and a SHA-256 checksum.
# Verify checksum
shasum -a 256 -c nvnm-mcp-server-v1.0.0-rc16-<os>-<arch>.sha256
# Verify Cosign signature
cosign verify-blob \
--certificate nvnm-mcp-server-v1.0.0-rc16-<os>-<arch>.cert.pem \
--signature nvnm-mcp-server-v1.0.0-rc16-<os>-<arch>.sig \
--certificate-identity-regexp 'https://github.com/NVNM-Chain/nvnm-mcp-server/.*' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
nvnm-mcp-server-v1.0.0-rc16-<os>-<arch>v1.0.0-rc15
Security
- Scoped
id-token: write(andpackages/contents: write) from workflow
level down to the specific jobs that need them
(.github/workflows/ci.yml,
image.yml,
release.yml). All three workflows granted
id-token: writeat workflow scope, so every job in them could mint a
Sigstore-attributable OIDC token — including jobs that never sign. Job-level
permissionsblocks replace workflow inheritance, so the token now reaches
only the Cosign signing jobs (check,image,build); the publish job
(release) keepscontents: writebut notid-token, and any future job
added without a block inherits the minimalcontents: readdefault. Surfaced
by the pre-red-team security assessment (finding SC-3). No image change. - Bumped
google.golang.org/grpcv1.80.0 → v1.82.1 to clear a High
Dependabot alert (GHSA gRPC-Go xDS RBAC / HTTP/2, CVSS 8.8). gRPC is an
indirect dependency pulled transitively viagooseand the OTLP/gRPC
telemetry exporters. The advisory affects gRPC's xDS RBAC authorization engine
and HTTP/2 transport server; this server never runs a gRPC server and does
not use xDS (our only gRPC use is the OTLP exporter client), so the vulnerable
code is not reachable — this is a hygiene bump to keep the public alert page
clean. Pulledgolang.org/x/oauth2andgenproto/googleapis/apiforward in
step (MVS; both permissively licensed). No first-party code change. - Made the Docker image build hermetic (Dockerfile). The
builder stage now compiles with-mod=vendoragainst the committedvendor/
tree and no longer runsgo mod download, so the image build never contacts
the module proxy. This removes the proxy-flake failure mode that hit PR #47's
image job and makes the compiled dependency set supply-chain-deterministic
(what is reviewed invendor/is exactly what is built). No change to the
compiled binary. Spotted during the pre-red-team security assessment. - Masked the caller email in the public key-request log line
(internal/mcp/keys_request_http.go). The
unauthenticatedPOST /api/v1/keys/requesthandler logged the full requester
email at INFO; it now logs only a masked form (a***@example.com) via a new
logging.SafeEmailhelper.request_idalready links to the full stored
record, so no operator capability is lost. Surfaced by the security assessment
(finding LG-3). - Bounded the upstream broadcast error persisted to the write-audit trail
(internal/mcp/tools_evm_write.go). The
node-returned error string on a failed broadcast is now capped at 512
characters before it reacheswrite_audit.Erroror the audit log. The client
never sees this error (SafeForClientcollapses it), but an unbounded reply
from a hostile/MITM'd node could otherwise bloat the audit column and log sink.
Surfaced by the security assessment (finding LG-2). KEY_HMAC_PEPPERnow has a boot-time length floor
(internal/config/config.go). A set pepper
(active or previous) shorter than 32 characters fails boot with
ErrPepperTooShort. Previously any non-empty value was accepted, so a weak,
brute-forceable pepper silently weakened the key-hash MAC while giving the
false confidence of "peppered." Unset (unpeppered) mode is unchanged. Operators
running a pepper shorter than 32 characters must rotate to a ≥ 32-character
high-entropy secret before upgrading. Surfaced by the pre-red-team security
assessment (finding KS-2).- Hardened the untrusted RPC-node boundary against denial of service
(internal/evm, internal/anchor). Node/RPC
responses are untrusted (a plaintexthttp://endpoint is permitted, so a
hostile or MITM'd node controls the bytes). Two gaps are closed: (EV-1) every
response body is now capped at 32 MiB via a limitinghttp.RoundTripper, so an
unbounded reply can no longer exhaust process memory before decode; and (EV-2)
the node-response decode/normalize paths now run underrecover(), converting
a malformed-response panic into anErrNodeResponseDecodeerror instead of
crashing the stdio process. This mirrors therecover()+ size cap already on
the caller-transaction decode path and satisfies supplement invariant INV-6.
Surfaced by the pre-red-team security assessment (findings EV-1, EV-2). - Fuzz-hardened the untrusted decode boundary
(internal/evm/decode_fuzz_test.go,
internal/anchor/decode_fuzz_test.go).
Go native fuzz targets for the caller-tx decode (11.3M execs) and the anchor
ABI decode (3.4M execs). The caller path held every invariant (no panic
escapesrecover();CanonicalRawis a signer fixed-point — the
parser-differential defense). Fuzzing also corrects an earlier
characterization: thedefiwebABI decoder is not bounds-checked — an
unguarded probe panicked it in 1.6s with a 29-byte input (slice bounds out of range [-8388608:], a length prefix decoded as a negative index). The EV-2
anchor guard therefore defends a real, trivially-craftable hostile-node DoS,
not a hypothetical one; that crashing input is now a regression seed. - Pinned the CI license-check tool:
go-licenses@latest→@v1.6.0
(.github/workflows/ci.yml). An unpinned
go install ...@latestresolves to whatever the module proxy serves at run
time and executes arbitrary Go on the runner — a supply-chain sink,
particularly in a job that carriesid-token: write. It sat directly beside a
deliberately-pinnedgovulncheck@v1.3.0; this closes the asymmetry. v1.6.0
verified to run clean under the pinned Go toolchain. Surfaced by the pre-red-team
security assessment (finding SC-2). No image change.
Fixed
- Corrected an over-broad logging claim in the README. The Observability
section stated sensitive data "is redacted in all log output"; in fact the
SafeAddr/SafeURL/SafeTxDatahelpers redact on debug lines, while the
write-audit broadcast lines (INFO/WARN) deliberately record the recovered
on-chain signer, destination, and value in full (public on-chain data forming
the audit trail). README now matches the code and cross-references
docs/DATA_HANDLING.md. Surfaced by the security assessment (finding LG-1,
doc leg). The in-code posture is now resolved (see Documentation below):
logging these identifiers is retained by design; the log sink's retention is
documented as the operator's responsibility.
Documentation
- Corrected the audit-attribution claim in
docs/SECURITY_CONSUMER_GUIDANCE.md
(finding CG-1). "Trace which API key created or modified an offending record"
was false: the write-audit is keyed by the recovered on-chain signer (there
is noclient_idcolumn, and anonymous keyless writes carry no API key). The
claim now describes signer-based attribution, with the authenticated caller's
client_idin the audit log line. - Documented the write-audit log sink as outside the retention purge
(finding LG-1,docs/DATA_HANDLING.md§ 8.3). The in-process purge deletes DB
rows only; the same signer/destination/value appear in structured audit log
lines governed by the operator's log-pipeline retention. Logging them is
deliberate (operator abuse-forensics; values are already public on-chain) and
accepted — a published retention period must configure log retention to match. - Documented the per-signer quota as a soft ceiling under concurrency
(finding EA-1,docs/DATA_HANDLING.md§ 8.2 +SignerQuotaStoregodoc). The
non-atomicCount→Incrementcan over-admit pastMCP_SIGNER_WRITE_RATEby
roughly the concurrency width; an accepted trade-off for a coarse, gas-bounded
anti-abuse throttle (an exact cap would need a per-signer lock on every
broadcast). No behavior change.
Verifying signatures
Each binary is shipped with a Cosign keyless signature (via Sigstore OIDC) and a SHA-256 checksum.
# Verify checksum
shasum -a 256 -c nvnm-mcp-server-v1.0.0-rc15-<os>-<arch>.sha256
# Verify Cosign signature
cosign verify-blob \
--certificate nvnm-mcp-server-v1.0.0-rc15-<os>-<arch>.cert.pem \
--signature nvnm-mcp-server-v1.0.0-rc15-<os>-<arch>.sig \
--certificate-identity-regexp 'https://github.com/NVNM-Chain/nvnm-mcp-server/.*' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
nvnm-mcp-server-v1.0.0-rc15-<os>-<arch>v1.0.0-rc14
Changed
-
evm_send_raw_transactionrelay scope is now enforced on the authenticated
path too, not only under keyless writes. The tool is a scoped anchoring
relay: the server decodes each signed transaction and refuses to broadcast
unless its destination is the anchor precompile (ANCHOR_ADDRESS) — other
contracts, externally-owned accounts, native value transfers, and contract
creation are rejected with no broadcast. Previously this check ran only on the
anonymous keyless-write path; the authenticated/self-host path decoded
best-effort and relayed any signed transaction. Now both paths share one gate
(decodeAndScope), so a caller's signature can never be relayed through this
server to move funds or reach an arbitrary contract. The authenticated path
still passes the caller's original bytes through (keyless broadcasts the
canonical re-encode). Behavior change for self-hosters: an authenticated write
to a non-anchor destination — or a signed transaction the decoder cannot parse
— is now rejected by default where it previously broadcast.New escape hatch
MCP_RELAY_ALLOW_ANY(defaultfalse) restores the prior
unscoped best-effort relay on the authenticated path only, for self-host
operators who genuinely need to broadcast non-anchor transactions (registry
deploys, admin ops, exotic tx types). It has no effect under keyless writes
and is a boot error (ErrRelayAllowAnyWithKeyless) if combined with
MCP_KEYLESS_WRITES=true— anonymous writes are always pinned to the
precompile. Reads of any data remain unrestricted. Documented inREADME.md
(Write Architecture + env table),docs/TOOL_REFERENCE.md§16, and
.env.example. -
Privacy Policy revised for the authless architecture, effective 2026-07-14.
The published Policy now describes the Service as it actually runs: no
authentication on any tool, abuse controlled by the on-chain signer address
recovered from each broadcast, and the anchor-precompile-only relay
restriction. The retention schedule in §8 is the one the in-code purge
enforces (shipped in rc13).Validated against the codebase before publication. Corrections made as a
result: §3(c) now discloses themethod_selectorcolumn (the four-byte
public ABI function identifier stored alongside the calldata length);
§3(d)'s IP-logging sentence now matches the code, which logs a caller IP only
on error, authentication-rejection, Origin-rejection, and rate-limit paths
and never on an accepted tool call; §8'sadmin_auditrow no longer refers to
"account closure" (the Service has no user accounts); and §10 no longer claims
an Origin allowlist is enforced on every request, since a request carrying
no Origin header is not rejected on that basis — the DNS-rebinding defence the
MCP specification actually requires.The file is renamed
docs/NVNM_MCP_Privacy_Policy_Jun_2026.pdf→
docs/NVNM_MCP_Privacy_Policy_Jul_2026.pdfto match its new effective date;
the four references inREADME.mdanddocs/TERMS.mdare updated in the same
commit.
Security
- Bumped
golang.org/x/cryptov0.50.0 → v0.54.0 to clear seven Dependabot
alerts (all SSH-package vulnerabilities in that module:VerifiedPublicKeyCallback
permission-skip, client-driven server deadlock, agent-constraint drop on key
forwarding,@revokedauth-bypass, pathological RSA/DSA DoS, certificate-
restriction bypass, and aCheckHostKey/Authenticateserver panic). The
vulnerablex/crypto/sshcode is not reachable from this server — no package
in the module importsx/crypto(go mod whyconfirms it is a transitive-only
requirement viagooseandx/net) — so this is a hygiene bump to keep the
alert page clean ahead of public release, not a fix for an exploitable path.
The upgrade pulled the rest of thegolang.org/x/*family up in step
(net0.53→0.56,sys0.43→0.47,text0.36→0.40,sync0.20→0.22); all
BSD-3-Clause. Vendor tree re-synced;go test -race ./...green.
Removed
-
The opt-in Docker Hub mirror is gone from the image workflow.
image.yml
carried a mirror that, when the repo variableDOCKERHUB_IMAGEwas set,
logged intodocker.iowithDOCKERHUB_USERNAME/DOCKERHUB_TOKENand
re-tagged each GHCR manifest onto Docker Hub. It was never enabled — the
variable was never set, so the steps always skipped — and mirroring has in
practice been done by hand.It was removed rather than left dormant. This repository is headed for public
release, and a workflow step that references a private-registry credential
is a reachable sink for that credential the moment one repo variable is set;
"currently skipped" is a configuration state, not a security boundary.
GHCR is now the sole registry this workflow publishes to, and the workflow
holds no credential for any other.No supply-chain guarantee is lost. The mirror never rebuilt anything — it
re-tagged the same content-addressable digest — so an image mirrored by hand
(docker buildx imagetools create -t docker.io/<org>/nvnm-mcp-server:<tag> ghcr.io/nvnm-chain/nvnm-mcp-server:<tag>) is byte-identical to what the
automated step produced and carries the same Cosign signature over that
digest. Publishing to a second registry is now a deliberate, human act.
Verifying signatures
Each binary is shipped with a Cosign keyless signature (via Sigstore OIDC) and a SHA-256 checksum.
# Verify checksum
shasum -a 256 -c nvnm-mcp-server-v1.0.0-rc14-<os>-<arch>.sha256
# Verify Cosign signature
cosign verify-blob \
--certificate nvnm-mcp-server-v1.0.0-rc14-<os>-<arch>.cert.pem \
--signature nvnm-mcp-server-v1.0.0-rc14-<os>-<arch>.sig \
--certificate-identity-regexp 'https://github.com/NVNM-Chain/nvnm-mcp-server/.*' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
nvnm-mcp-server-v1.0.0-rc14-<os>-<arch>v1.0.0-rc13
Cumulative since v1.0.0-rc10: rc11 and rc12 were cut without CHANGELOG
entries, so the changes they carried are recorded here rather than lost.
Fixed
-
Container images now report their real version. The Dockerfile built with
-ldflags="-s -w"and no-Xinjection, and the image workflow passed no
version build-arg, so the binary fell back to the hardcoded
version.Version = "1.0.0-rc10". Every image from rc11 onward reported
itself as1.0.0-rc10— the image tag said one thing andserverInfo/
/healthzsaid another.release.ymlalready injected the version correctly
into the standalone binaries; the image build was the one path that did not.The Dockerfile now takes
ARG VERSIONand injects it via-X, and
image.ymlpasses${{ steps.meta.outputs.version }}. The fallback in
internal/versionis nowdev, not a release-shaped number: a
plausible-but-wrong version is worse than an obviously-unset one, because it
fails silently. That stale constant is exactly why this went unnoticed across
two release candidates.
Added
-
Operator-configured data retention with an enforcing purge. The four
Postgres tables the keyless bundle writes (write_audit,signer_quota,
signer_blacklist,admin_audit) previously had no purge, TTL, or expiry
of any kind — rows accumulated indefinitely, and the retention periods our
own documentation cited were enforced by nothing. A new in-process purge now
enforces per-table windows:MCP_WRITE_AUDIT_RETENTION,
MCP_WRITE_AUDIT_GRANT_ROLE_RETENTION,MCP_SIGNER_QUOTA_RETENTION,
MCP_SIGNER_BLACKLIST_RETENTION,MCP_ADMIN_AUDIT_RETENTION, and
MCP_RETENTION_PURGE_INTERVAL(default1h).Every window defaults to unset, which means retain indefinitely — no
existing deployment changes behavior on upgrade. Retention is deliberately
operator-chosen rather than hardcoded: a self-hosting operator's obligations
are theirs to determine. The point of the feature is that a window an
operator does set is now enforced by the artifact rather than promised by a
document. Boot fails on a negative window, on a window with a non-positive
purge interval (it would never fire), and on agrantRolewindow shorter
than the ordinary one (it would purge the administrative trail before the
routine traffic it exists to outlive). Deletes are batched so a large first
sweep cannot hold long row locks. Seedocs/DATA_HANDLING.md§ 8.3 and
docs/RUNBOOK.md§ "Data retention". -
write_audit.method_selector(migration 0005). Records the 4-byte ABI
method selector of each broadcast. Under keyless writes every relayed
transaction shares one destination (checkRelayScopepermits only the anchor
precompile), soto_addrcould not distinguish an administrativegrantRole
call from a routine anchor write — which made the longergrantRole
retention window our privacy policy promises unenforceable. The selector
is derived from the loaded ABI (anchor.Client.MethodSelector), never
hardcoded: this precompile'sgrantRoletakes four arguments, so its selector
is not the well-known OpenZeppelingrantRole(bytes32,address)value. A
selector is a public function identifier and carries no caller data. Rows
written before this migration carry an empty selector and are treated as
ordinary writes. -
Trusted-proxy header hardening (C3/C5). Two defense-in-depth
controls, both gated on the existingNVNM_TRUST_PROXY_HEADERS
(defaultfalse, unchanged): (C3) the fail-rate and anon-read
limiters' client-IP derivation now walks a configurable number of
trusted hops in from the right ofX-Forwarded-For ++ RemoteAddr
instead of trusting the leftmost (client-forgeable)XFFentry, so
a forged left-prefix can no longer mint its own rate-limit bucket;
(C5) a newrequireForwardedHTTPSmiddleware rejects a request
carrying an explicit non-httpsX-Forwarded-Protowith403,
catching a plaintext-downgrade path inside the trust boundary — it
is deliberately lenient whenX-Forwarded-Protois absent (the
ingress remains the primary, fail-closed TLS gate; see
docs/RUNBOOK.md§ "Trusted-proxy header invariants (C3/C5)" for
the fail-open rationale). New env varNVNM_TRUSTED_PROXY_HOPS
(int, default1, must be>= 1; boot fails loud otherwise) sets
the trusted chain depth (1= single ingress,2= CDN +
ingress); documented canonically indocs/DATA_HANDLING.md§ 5.
Both controls are defense-in-depth behind the deploy-side
invariants (proxy strips inboundXFF; ingress setsXFPand
terminates TLS), which remain operator-owned. -
API-key hashing now supports HMAC-SHA256 under a server-held pepper
(KEY_HMAC_PEPPER, optional;KEY_HMAC_PEPPER_PREVIOUSfor one
rotation window). Keys carry ahash_version; legacy SHA-256 keys
(v0) continue to authenticate unchanged via versioned candidate
lookup. SettingKEY_HMAC_PEPPER_PREVIOUSwithoutKEY_HMAC_PEPPER
fails boot. -
Postgres key-store backend (
KEY_STORE_BACKEND=postgres). An
optional Postgres-backed key store for multi-replica deployments where
all replicas must share a single authoritativeapi_keystable.
File backend (KEY_STORE_BACKEND=file) remains the default and is
unchanged. New env vars:KEY_STORE_BACKEND,KEY_STORE_DSN
(required whenpostgres). Dependencies added:pgx/v5,goose/v3. -
Advisory-locked boot-time migrations. goose migrations for the
Postgres backend run automatically at startup under a
pg_advisory_lock, making concurrent replica boot safe without a
separate migration job. -
Persisted lazy v0→v1 rehash (Postgres backend). On first
authenticated use, a legacyv0key (plain SHA-256) is transparently
rehashed tov1(HMAC-SHA256 underKEY_HMAC_PEPPER) and the updated
digest is committed to theapi_keystable. -
Hard pepper gate (
ErrPepperRequired). When
KEY_STORE_BACKEND=postgresandAUTH_PROVIDER=apikey, boot fails
immediately ifKEY_HMAC_PEPPERis unset. Closes the risk of a
misconfigured replica falling back to unprotected v0 hashing on a
shared store. -
Key expiry enforcement (Phase 4).
expires_atis now enforced at
Lookup on both file and Postgres backends. A key expires when
now >= expires_at; a zero/NULLexpires_atnever expires. -
Three-way reject taxonomy with bounded disclosure. Auth rejections
are classified into three messages:invalid API key(no-match —
also returned on the timing-equalised miss path so non-holders cannot
distinguish unknown-key from known-key),key expired(matched row,
expiry passed; appends— renew at <KEY_RENEWAL_URL>when set), and
key revoked(matched row,enabled=false). The specific messages
are only reachable by a caller holding the matched key's bytes. -
KEY_DEFAULT_TTLenv var (default8760h≈ 1 year). Applied by
the admin REST API andkey-mgmt createwhen no per-keyttl
override is given.0means no default expiry. Does not apply to the
staticMCP_API_KEYpath (always non-expiring). -
KEY_RENEWAL_URLenv var (optional). URL appended to thekey expiredrejection message; empty = no hint appended. -
Admin REST API TTL support.
POST /admin/keysand
PATCH /admin/keys/{id}accept an optional"ttl"field (duration
string;"0"/"none"/"never"= no expiry; non-positive parsed
duration → HTTP 400). Admin responses (KeySummary) now include
expires_at; the zero-value0001-01-01T00:00:00Zmeans no expiry. -
key-mgmtCLI TTL and renew.key-mgmt creategains--ttl <dur|0>(default fromKEY_DEFAULT_TTL). Newkey-mgmt renew <client-id> --ttl <dur|0>subcommand extends or clears expiry from
now;--ttlis required (the default TTL is not applied on renew).
Fixed
nvnm_overviewno longer tells agents a keyless deployment requires an
API key. Theprereqslist was a package-level constant asserting that
broadcasting needs "an API key on this server with the writer or admin
role" — false since the authless flip, and model-visible to every connecting
client. It is now derived fromcfg.KeylessWrites, the same flag
RequiresAuthreads to make the actual enforcement decision, so the stated
requirement cannot drift from the enforced one. A keyless deployment now
reports that no credential is required and that the wallet signature is the
only identity the server sees.docs/RUNBOOK.mdno longer claims a hosted authless deployment "runs no
key store at all." The code does not permit that:loadAPIKeysfails boot
withErrHTTPAuthRequiredwhen neitherMCP_API_KEYnor
MCP_API_KEYS_FILEis set on the HTTP transport, and that guard has no
keyless exception. Such a deployment holds an operator credential that is
never issued to any caller; callers remain wholly anonymous, but the
Bearer-validation path stays mounted and an invalid token draws a401. The
same incorrect claim is corrected ininternal/config'sKeylessPGDSN
godoc.
Verifying signatures
Each binary is shipped with a Cosign keyless signature (via Sigstore OIDC) and a SHA-256 checksum.
# Verify checksum
shasum -a 256 -c nvnm-mcp-server-v1.0.0-rc13-<os>-<arch>.sha256
# Verify Cosign signature
cosign verify-blob \
--certificate nvnm-mcp-server-v1.0.0-rc13-<os>-<arch>.cert.pem \
--signature nvnm-mcp-server-v1.0.0-rc13-<os>-<arch>.sig \
--certificate-identity-regexp 'https://github.com/NVNM-Chain/nvnm-mcp-server/.*' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
nvnm-mcp-server-v1.0.0-rc13-<os>-<arch>v1.0.0-rc12
Added
- Trusted-proxy header hardening (C3/C5). Two defense-in-depth
controls, both gated on the existingNVNM_TRUST_PROXY_HEADERS
(defaultfalse, unchanged): (C3) the fail-rate and anon-read
limiters' client-IP derivation now walks a configurable number of
trusted hops in from the right ofX-Forwarded-For ++ RemoteAddr
instead of trusting the leftmost (client-forgeable)XFFentry, so
a forged left-prefix can no longer mint its own rate-limit bucket;
(C5) a newrequireForwardedHTTPSmiddleware rejects a request
carrying an explicit non-httpsX-Forwarded-Protowith403,
catching a plaintext-downgrade path inside the trust boundary — it
is deliberately lenient whenX-Forwarded-Protois absent (the
ingress remains the primary, fail-closed TLS gate; see
docs/RUNBOOK.md§ "Trusted-proxy header invariants (C3/C5)" for
the fail-open rationale). New env varNVNM_TRUSTED_PROXY_HOPS
(int, default1, must be>= 1; boot fails loud otherwise) sets
the trusted chain depth (1= single ingress,2= CDN +
ingress); documented canonically indocs/DATA_HANDLING.md§ 5.
Both controls are defense-in-depth behind the deploy-side
invariants (proxy strips inboundXFF; ingress setsXFPand
terminates TLS), which remain operator-owned. - API-key hashing now supports HMAC-SHA256 under a server-held pepper
(KEY_HMAC_PEPPER, optional;KEY_HMAC_PEPPER_PREVIOUSfor one
rotation window). Keys carry ahash_version; legacy SHA-256 keys
(v0) continue to authenticate unchanged via versioned candidate
lookup. SettingKEY_HMAC_PEPPER_PREVIOUSwithoutKEY_HMAC_PEPPER
fails boot. - Postgres key-store backend (
KEY_STORE_BACKEND=postgres). An
optional Postgres-backed key store for multi-replica deployments where
all replicas must share a single authoritativeapi_keystable.
File backend (KEY_STORE_BACKEND=file) remains the default and is
unchanged. New env vars:KEY_STORE_BACKEND,KEY_STORE_DSN
(required whenpostgres). Dependencies added:pgx/v5,goose/v3. - Advisory-locked boot-time migrations. goose migrations for the
Postgres backend run automatically at startup under a
pg_advisory_lock, making concurrent replica boot safe without a
separate migration job. - Persisted lazy v0→v1 rehash (Postgres backend). On first
authenticated use, a legacyv0key (plain SHA-256) is transparently
rehashed tov1(HMAC-SHA256 underKEY_HMAC_PEPPER) and the updated
digest is committed to theapi_keystable. - Hard pepper gate (
ErrPepperRequired). When
KEY_STORE_BACKEND=postgresandAUTH_PROVIDER=apikey, boot fails
immediately ifKEY_HMAC_PEPPERis unset. Closes the risk of a
misconfigured replica falling back to unprotected v0 hashing on a
shared store. - Key expiry enforcement (Phase 4).
expires_atis now enforced at
Lookup on both file and Postgres backends. A key expires when
now >= expires_at; a zero/NULLexpires_atnever expires. - Three-way reject taxonomy with bounded disclosure. Auth rejections
are classified into three messages:invalid API key(no-match —
also returned on the timing-equalised miss path so non-holders cannot
distinguish unknown-key from known-key),key expired(matched row,
expiry passed; appends— renew at <KEY_RENEWAL_URL>when set), and
key revoked(matched row,enabled=false). The specific messages
are only reachable by a caller holding the matched key's bytes. KEY_DEFAULT_TTLenv var (default8760h≈ 1 year). Applied by
the admin REST API andkey-mgmt createwhen no per-keyttl
override is given.0means no default expiry. Does not apply to the
staticMCP_API_KEYpath (always non-expiring).KEY_RENEWAL_URLenv var (optional). URL appended to thekey expiredrejection message; empty = no hint appended.- Admin REST API TTL support.
POST /admin/keysand
PATCH /admin/keys/{id}accept an optional"ttl"field (duration
string;"0"/"none"/"never"= no expiry; non-positive parsed
duration → HTTP 400). Admin responses (KeySummary) now include
expires_at; the zero-value0001-01-01T00:00:00Zmeans no expiry. key-mgmtCLI TTL and renew.key-mgmt creategains--ttl <dur|0>(default fromKEY_DEFAULT_TTL). Newkey-mgmt renew <client-id> --ttl <dur|0>subcommand extends or clears expiry from
now;--ttlis required (the default TTL is not applied on renew).
Fixes from the rc9 dmome21 full smoke test (testnet, real MCP client),
re-verified end-to-end against a local rc10 binary over the real MCP protocol.
Added
evm_call_contractaccepts an optionalfromaddress so callers can
simulate permissioned reads that checkmsg.sender; omitting it preserves
the prior zero-address behavior. (smoke F6)
Changed
- Recognized, caller-input precompile revert reasons (e.g. an oversized
checksum) now surface to the client as an actionable message instead of the
genericupstream operation failed. Reasons are drawn from a fixed allowlist
at the anchor boundary and carried by a newErrPrecompileValidation
sentinel; raw chain text (internal type paths) still collapses. (smoke F5)
Fixed
nvnm_setup_verify_hash/nvnm_setup_verify_signature: a hash/signature
mismatch is now returned as a successful result withok: falseand the
full remediation payload (challenge,expected/recovered_address,
next_actions) instead of an error. The SDK discards structured output when
a handler returns an error, so the remediation the tools were designed to
give was previously dropped over the wire. Challenge derivation is now
documented in the tool descriptions. (smoke F2)anchor_prepare_add_record: the server now rejects the empty JSON object
{}(and whitespace-only variants) formetadatawith an actionable
message, matching the anchoring precompile, which rejects it on-chain. Prior
guidance to "pass{}" was corrected in the tool schema, README, and
MetaMask guide. (smoke F3a)anchor_prepare_grant_role: tool description now states it requires the
adminrole only, matching enforcement; it previously inherited shared prose
that incorrectly advertised the writer/admin/automation roles. (smoke F4)
Fixed
anchor_prepare_add_recordinput ergonomics (rc8 E2E findings). The
anchoring precompile rejects an emptychecksum_algoormetadataand caps
checksumat 64 chars, but those constraints were undiscoverable from the
tool schema and surfaced only as opaque gas-estimation errors. Now:
checksum_algoandmetadataare marked required in the tool schema and
validated client-side (fail-loud, wrappingmissing required parameter)
before any RPC; and achecksumpassed in its natural0x-prefixed form is
stripped to the bare hex digest server-side so both representations work
(anchor_prepare_grant_role's optional record-scopingchecksumis
normalized the same way). Documented inTOOL_REFERENCE.md; the
METAMASK_GUIDE.mdandREADME.mdexamples were corrected to pass valid,
complete inputs. No on-chain behavior change — purely input validation and
normalization at the prepare boundary.anchor_get_recordshonoredregistry_id(rc8 E2E follow-on). The
precompile's records query is keyed by registry name, so aregistry_id
filter was silently ignored — the registry_id-based lookup modes advertised by
the tool returned an empty set even when the registry held records (a silent
wrong answer, not an error).GetRecordsnow resolvesregistry_id → name
internally (an explicitregistryname still wins; an unknown id fails loud
with aresolve registry_iderror). Documented the id/name interchangeability
inTOOL_REFERENCE.md. Integration tests assert id and name return the same
records and that a bad id errors.- Clarified
pagination.totalis not authoritative. The nvnm-testnet-1
precompile returnspagination.total: 0even withcountTotal=trueand
records present (a chain-side limitation, not a client decode bug — the
decode struct matches the ABI). Documented onanchor_get_recordsand
anchor_get_registriesthat callers should page using the length of the
returned slice pluslimit/offset, nottotal. evm_get_transactionnow populatesfromand reports not-found explicitly
(rc8 E2E findings). The transaction normalizer never mapped the recovered
sender, so a real mined tx came back with an emptyfrom; it is now mapped.
Separately, a well-formed but non-existent hash yielded a zero-value object
that read asis_pending: truewith an empty hash, because the not-found
guard only checked for a nil transaction (the RPC/decoder returns a non-nil
empty struct). It now also treats a missing hash as not-found (a real
transaction — pending or mined — always carries a hash) and returns
ErrTxNotFound.anchor_get_registry/anchor_get_recordsno longer leak the internal
Cosmos proto type path on a not-found. The precompile returns a raw
collections: not found … of type …mantrachain.anchoring.v1.Registryerror
for an unknown registry id; it is now mapped to the cleanErrRegistryNotFound
sentinel at the anchor-client boundary.
Security
- Tool-handler errors are now sanitized before reaching the client. Errors
returned from a tool handler are surfaced by the MCP SDK asCallToolResult
content, which bypassed the receiving middleware'sSafeForClient(that only
sees protocol-level method errors). As a result, raw upstream error text —
RPC failures, gas-estimation reverts, decode errors, internal Cosmos proto
type paths — could leak verbatim to clients. All 21 tools are now registered
through a singleaddToolwrapper that routes the handler's error through
SafeForClient: known sentinels (not-found, auth, permission, input...
v1.0.0-rc11
Added
- Trusted-proxy header hardening (C3/C5). Two defense-in-depth
controls, both gated on the existingNVNM_TRUST_PROXY_HEADERS
(defaultfalse, unchanged): (C3) the fail-rate and anon-read
limiters' client-IP derivation now walks a configurable number of
trusted hops in from the right ofX-Forwarded-For ++ RemoteAddr
instead of trusting the leftmost (client-forgeable)XFFentry, so
a forged left-prefix can no longer mint its own rate-limit bucket;
(C5) a newrequireForwardedHTTPSmiddleware rejects a request
carrying an explicit non-httpsX-Forwarded-Protowith403,
catching a plaintext-downgrade path inside the trust boundary — it
is deliberately lenient whenX-Forwarded-Protois absent (the
ingress remains the primary, fail-closed TLS gate; see
docs/RUNBOOK.md§ "Trusted-proxy header invariants (C3/C5)" for
the fail-open rationale). New env varNVNM_TRUSTED_PROXY_HOPS
(int, default1, must be>= 1; boot fails loud otherwise) sets
the trusted chain depth (1= single ingress,2= CDN +
ingress); documented canonically indocs/DATA_HANDLING.md§ 5.
Both controls are defense-in-depth behind the deploy-side
invariants (proxy strips inboundXFF; ingress setsXFPand
terminates TLS), which remain operator-owned. - API-key hashing now supports HMAC-SHA256 under a server-held pepper
(KEY_HMAC_PEPPER, optional;KEY_HMAC_PEPPER_PREVIOUSfor one
rotation window). Keys carry ahash_version; legacy SHA-256 keys
(v0) continue to authenticate unchanged via versioned candidate
lookup. SettingKEY_HMAC_PEPPER_PREVIOUSwithoutKEY_HMAC_PEPPER
fails boot. - Postgres key-store backend (
KEY_STORE_BACKEND=postgres). An
optional Postgres-backed key store for multi-replica deployments where
all replicas must share a single authoritativeapi_keystable.
File backend (KEY_STORE_BACKEND=file) remains the default and is
unchanged. New env vars:KEY_STORE_BACKEND,KEY_STORE_DSN
(required whenpostgres). Dependencies added:pgx/v5,goose/v3. - Advisory-locked boot-time migrations. goose migrations for the
Postgres backend run automatically at startup under a
pg_advisory_lock, making concurrent replica boot safe without a
separate migration job. - Persisted lazy v0→v1 rehash (Postgres backend). On first
authenticated use, a legacyv0key (plain SHA-256) is transparently
rehashed tov1(HMAC-SHA256 underKEY_HMAC_PEPPER) and the updated
digest is committed to theapi_keystable. - Hard pepper gate (
ErrPepperRequired). When
KEY_STORE_BACKEND=postgresandAUTH_PROVIDER=apikey, boot fails
immediately ifKEY_HMAC_PEPPERis unset. Closes the risk of a
misconfigured replica falling back to unprotected v0 hashing on a
shared store. - Key expiry enforcement (Phase 4).
expires_atis now enforced at
Lookup on both file and Postgres backends. A key expires when
now >= expires_at; a zero/NULLexpires_atnever expires. - Three-way reject taxonomy with bounded disclosure. Auth rejections
are classified into three messages:invalid API key(no-match —
also returned on the timing-equalised miss path so non-holders cannot
distinguish unknown-key from known-key),key expired(matched row,
expiry passed; appends— renew at <KEY_RENEWAL_URL>when set), and
key revoked(matched row,enabled=false). The specific messages
are only reachable by a caller holding the matched key's bytes. KEY_DEFAULT_TTLenv var (default8760h≈ 1 year). Applied by
the admin REST API andkey-mgmt createwhen no per-keyttl
override is given.0means no default expiry. Does not apply to the
staticMCP_API_KEYpath (always non-expiring).KEY_RENEWAL_URLenv var (optional). URL appended to thekey expiredrejection message; empty = no hint appended.- Admin REST API TTL support.
POST /admin/keysand
PATCH /admin/keys/{id}accept an optional"ttl"field (duration
string;"0"/"none"/"never"= no expiry; non-positive parsed
duration → HTTP 400). Admin responses (KeySummary) now include
expires_at; the zero-value0001-01-01T00:00:00Zmeans no expiry. key-mgmtCLI TTL and renew.key-mgmt creategains--ttl <dur|0>(default fromKEY_DEFAULT_TTL). Newkey-mgmt renew <client-id> --ttl <dur|0>subcommand extends or clears expiry from
now;--ttlis required (the default TTL is not applied on renew).
Fixes from the rc9 dmome21 full smoke test (testnet, real MCP client),
re-verified end-to-end against a local rc10 binary over the real MCP protocol.
Added
evm_call_contractaccepts an optionalfromaddress so callers can
simulate permissioned reads that checkmsg.sender; omitting it preserves
the prior zero-address behavior. (smoke F6)
Changed
- Recognized, caller-input precompile revert reasons (e.g. an oversized
checksum) now surface to the client as an actionable message instead of the
genericupstream operation failed. Reasons are drawn from a fixed allowlist
at the anchor boundary and carried by a newErrPrecompileValidation
sentinel; raw chain text (internal type paths) still collapses. (smoke F5)
Fixed
nvnm_setup_verify_hash/nvnm_setup_verify_signature: a hash/signature
mismatch is now returned as a successful result withok: falseand the
full remediation payload (challenge,expected/recovered_address,
next_actions) instead of an error. The SDK discards structured output when
a handler returns an error, so the remediation the tools were designed to
give was previously dropped over the wire. Challenge derivation is now
documented in the tool descriptions. (smoke F2)anchor_prepare_add_record: the server now rejects the empty JSON object
{}(and whitespace-only variants) formetadatawith an actionable
message, matching the anchoring precompile, which rejects it on-chain. Prior
guidance to "pass{}" was corrected in the tool schema, README, and
MetaMask guide. (smoke F3a)anchor_prepare_grant_role: tool description now states it requires the
adminrole only, matching enforcement; it previously inherited shared prose
that incorrectly advertised the writer/admin/automation roles. (smoke F4)
Fixed
anchor_prepare_add_recordinput ergonomics (rc8 E2E findings). The
anchoring precompile rejects an emptychecksum_algoormetadataand caps
checksumat 64 chars, but those constraints were undiscoverable from the
tool schema and surfaced only as opaque gas-estimation errors. Now:
checksum_algoandmetadataare marked required in the tool schema and
validated client-side (fail-loud, wrappingmissing required parameter)
before any RPC; and achecksumpassed in its natural0x-prefixed form is
stripped to the bare hex digest server-side so both representations work
(anchor_prepare_grant_role's optional record-scopingchecksumis
normalized the same way). Documented inTOOL_REFERENCE.md; the
METAMASK_GUIDE.mdandREADME.mdexamples were corrected to pass valid,
complete inputs. No on-chain behavior change — purely input validation and
normalization at the prepare boundary.anchor_get_recordshonoredregistry_id(rc8 E2E follow-on). The
precompile's records query is keyed by registry name, so aregistry_id
filter was silently ignored — the registry_id-based lookup modes advertised by
the tool returned an empty set even when the registry held records (a silent
wrong answer, not an error).GetRecordsnow resolvesregistry_id → name
internally (an explicitregistryname still wins; an unknown id fails loud
with aresolve registry_iderror). Documented the id/name interchangeability
inTOOL_REFERENCE.md. Integration tests assert id and name return the same
records and that a bad id errors.- Clarified
pagination.totalis not authoritative. The nvnm-testnet-1
precompile returnspagination.total: 0even withcountTotal=trueand
records present (a chain-side limitation, not a client decode bug — the
decode struct matches the ABI). Documented onanchor_get_recordsand
anchor_get_registriesthat callers should page using the length of the
returned slice pluslimit/offset, nottotal. evm_get_transactionnow populatesfromand reports not-found explicitly
(rc8 E2E findings). The transaction normalizer never mapped the recovered
sender, so a real mined tx came back with an emptyfrom; it is now mapped.
Separately, a well-formed but non-existent hash yielded a zero-value object
that read asis_pending: truewith an empty hash, because the not-found
guard only checked for a nil transaction (the RPC/decoder returns a non-nil
empty struct). It now also treats a missing hash as not-found (a real
transaction — pending or mined — always carries a hash) and returns
ErrTxNotFound.anchor_get_registry/anchor_get_recordsno longer leak the internal
Cosmos proto type path on a not-found. The precompile returns a raw
collections: not found … of type …mantrachain.anchoring.v1.Registryerror
for an unknown registry id; it is now mapped to the cleanErrRegistryNotFound
sentinel at the anchor-client boundary.
Security
- Tool-handler errors are now sanitized before reaching the client. Errors
returned from a tool handler are surfaced by the MCP SDK asCallToolResult
content, which bypassed the receiving middleware'sSafeForClient(that only
sees protocol-level method errors). As a result, raw upstream error text —
RPC failures, gas-estimation reverts, decode errors, internal Cosmos proto
type paths — could leak verbatim to clients. All 21 tools are now registered
through a singleaddToolwrapper that routes the handler's error through
SafeForClient: known sentinels (not-found, auth, permission, input...
v1.0.0-rc0
Added
- API-key hashing now supports HMAC-SHA256 under a server-held pepper
(KEY_HMAC_PEPPER, optional;KEY_HMAC_PEPPER_PREVIOUSfor one
rotation window). Keys carry ahash_version; legacy SHA-256 keys
(v0) continue to authenticate unchanged via versioned candidate
lookup. SettingKEY_HMAC_PEPPER_PREVIOUSwithoutKEY_HMAC_PEPPER
fails boot. - Postgres key-store backend (
KEY_STORE_BACKEND=postgres). An
optional Postgres-backed key store for multi-replica deployments where
all replicas must share a single authoritativeapi_keystable.
File backend (KEY_STORE_BACKEND=file) remains the default and is
unchanged. New env vars:KEY_STORE_BACKEND,KEY_STORE_DSN
(required whenpostgres). Dependencies added:pgx/v5,goose/v3. - Advisory-locked boot-time migrations. goose migrations for the
Postgres backend run automatically at startup under a
pg_advisory_lock, making concurrent replica boot safe without a
separate migration job. - Persisted lazy v0→v1 rehash (Postgres backend). On first
authenticated use, a legacyv0key (plain SHA-256) is transparently
rehashed tov1(HMAC-SHA256 underKEY_HMAC_PEPPER) and the updated
digest is committed to theapi_keystable. - Hard pepper gate (
ErrPepperRequired). When
KEY_STORE_BACKEND=postgresandAUTH_PROVIDER=apikey, boot fails
immediately ifKEY_HMAC_PEPPERis unset. Closes the risk of a
misconfigured replica falling back to unprotected v0 hashing on a
shared store. - Key expiry enforcement (Phase 4).
expires_atis now enforced at
Lookup on both file and Postgres backends. A key expires when
now >= expires_at; a zero/NULLexpires_atnever expires. - Three-way reject taxonomy with bounded disclosure. Auth rejections
are classified into three messages:invalid API key(no-match —
also returned on the timing-equalised miss path so non-holders cannot
distinguish unknown-key from known-key),key expired(matched row,
expiry passed; appends— renew at <KEY_RENEWAL_URL>when set), and
key revoked(matched row,enabled=false). The specific messages
are only reachable by a caller holding the matched key's bytes. KEY_DEFAULT_TTLenv var (default8760h≈ 1 year). Applied by
the admin REST API andkey-mgmt createwhen no per-keyttl
override is given.0means no default expiry. Does not apply to the
staticMCP_API_KEYpath (always non-expiring).KEY_RENEWAL_URLenv var (optional). URL appended to thekey expiredrejection message; empty = no hint appended.- Admin REST API TTL support.
POST /admin/keysand
PATCH /admin/keys/{id}accept an optional"ttl"field (duration
string;"0"/"none"/"never"= no expiry; non-positive parsed
duration → HTTP 400). Admin responses (KeySummary) now include
expires_at; the zero-value0001-01-01T00:00:00Zmeans no expiry. key-mgmtCLI TTL and renew.key-mgmt creategains--ttl <dur|0>(default fromKEY_DEFAULT_TTL). Newkey-mgmt renew <client-id> --ttl <dur|0>subcommand extends or clears expiry from
now;--ttlis required (the default TTL is not applied on renew).
Fixes from the rc9 dmome21 full smoke test (testnet, real MCP client),
re-verified end-to-end against a local rc10 binary over the real MCP protocol.
Added
evm_call_contractaccepts an optionalfromaddress so callers can
simulate permissioned reads that checkmsg.sender; omitting it preserves
the prior zero-address behavior. (smoke F6)
Changed
- Recognized, caller-input precompile revert reasons (e.g. an oversized
checksum) now surface to the client as an actionable message instead of the
genericupstream operation failed. Reasons are drawn from a fixed allowlist
at the anchor boundary and carried by a newErrPrecompileValidation
sentinel; raw chain text (internal type paths) still collapses. (smoke F5)
Fixed
nvnm_setup_verify_hash/nvnm_setup_verify_signature: a hash/signature
mismatch is now returned as a successful result withok: falseand the
full remediation payload (challenge,expected/recovered_address,
next_actions) instead of an error. The SDK discards structured output when
a handler returns an error, so the remediation the tools were designed to
give was previously dropped over the wire. Challenge derivation is now
documented in the tool descriptions. (smoke F2)anchor_prepare_add_record: the server now rejects the empty JSON object
{}(and whitespace-only variants) formetadatawith an actionable
message, matching the anchoring precompile, which rejects it on-chain. Prior
guidance to "pass{}" was corrected in the tool schema, README, and
MetaMask guide. (smoke F3a)anchor_prepare_grant_role: tool description now states it requires the
adminrole only, matching enforcement; it previously inherited shared prose
that incorrectly advertised the writer/admin/automation roles. (smoke F4)
Fixed
anchor_prepare_add_recordinput ergonomics (rc8 E2E findings). The
anchoring precompile rejects an emptychecksum_algoormetadataand caps
checksumat 64 chars, but those constraints were undiscoverable from the
tool schema and surfaced only as opaque gas-estimation errors. Now:
checksum_algoandmetadataare marked required in the tool schema and
validated client-side (fail-loud, wrappingmissing required parameter)
before any RPC; and achecksumpassed in its natural0x-prefixed form is
stripped to the bare hex digest server-side so both representations work
(anchor_prepare_grant_role's optional record-scopingchecksumis
normalized the same way). Documented inTOOL_REFERENCE.md; the
METAMASK_GUIDE.mdandREADME.mdexamples were corrected to pass valid,
complete inputs. No on-chain behavior change — purely input validation and
normalization at the prepare boundary.anchor_get_recordshonoredregistry_id(rc8 E2E follow-on). The
precompile's records query is keyed by registry name, so aregistry_id
filter was silently ignored — the registry_id-based lookup modes advertised by
the tool returned an empty set even when the registry held records (a silent
wrong answer, not an error).GetRecordsnow resolvesregistry_id → name
internally (an explicitregistryname still wins; an unknown id fails loud
with aresolve registry_iderror). Documented the id/name interchangeability
inTOOL_REFERENCE.md. Integration tests assert id and name return the same
records and that a bad id errors.- Clarified
pagination.totalis not authoritative. The nvnm-testnet-1
precompile returnspagination.total: 0even withcountTotal=trueand
records present (a chain-side limitation, not a client decode bug — the
decode struct matches the ABI). Documented onanchor_get_recordsand
anchor_get_registriesthat callers should page using the length of the
returned slice pluslimit/offset, nottotal. evm_get_transactionnow populatesfromand reports not-found explicitly
(rc8 E2E findings). The transaction normalizer never mapped the recovered
sender, so a real mined tx came back with an emptyfrom; it is now mapped.
Separately, a well-formed but non-existent hash yielded a zero-value object
that read asis_pending: truewith an empty hash, because the not-found
guard only checked for a nil transaction (the RPC/decoder returns a non-nil
empty struct). It now also treats a missing hash as not-found (a real
transaction — pending or mined — always carries a hash) and returns
ErrTxNotFound.anchor_get_registry/anchor_get_recordsno longer leak the internal
Cosmos proto type path on a not-found. The precompile returns a raw
collections: not found … of type …mantrachain.anchoring.v1.Registryerror
for an unknown registry id; it is now mapped to the cleanErrRegistryNotFound
sentinel at the anchor-client boundary.
Security
- Tool-handler errors are now sanitized before reaching the client. Errors
returned from a tool handler are surfaced by the MCP SDK asCallToolResult
content, which bypassed the receiving middleware'sSafeForClient(that only
sees protocol-level method errors). As a result, raw upstream error text —
RPC failures, gas-estimation reverts, decode errors, internal Cosmos proto
type paths — could leak verbatim to clients. All 21 tools are now registered
through a singleaddToolwrapper that routes the handler's error through
SafeForClient: known sentinels (not-found, auth, permission, input) pass
through unchanged; everything else collapses to a generic upstream-failure
message. Internal-implementation disclosure, not credential exposure (keys and
tokens are never placed in error text), but closed as defense-in-depth. - Bumped indirect
btcdtov0.24.2(clears Dependabot HIGHGHSA-27vh-h6mc-q6g8). Not a reachable risk:govulncheckreports 0 affected and nothing imports thebtcdroot module (go-ethuses the separatebtcec/v2); the bump clears the graph-level alert.CONTRIBUTING.md§8 gained a Dependabot-vs-govulnchecktriage note for vendored deps.
Changed
- Stateless multi-replica operation (Option 0). The MCP Streamable-HTTP
handler now runs withStreamableHTTPOptions{Stateless: true}. The server no
longer keeps a per-pod session map, so any replica can serve any request and
no load-balancer session affinity is required — plain round-robin scales
throughput. - Write approval is now the client/agent's responsibility, not the server's.
The server-side MCP elicitation prompt beforeevm_send_raw_transactionwas
the only server→client request and the sole reason sessions had to be sticky;
it has been removed. Writes gate on RBAC role (writer/admin/automation)
plusENABLE_WRITE_TOOLSonly. The `initialize...