Vendor-neutral Rust foundation for enterprise network-security MCP servers
a mechub project — sovereign network-security automation
mecmcp is the shared crate family underneath mechub's per-vendor MCP servers.
Authentication, attribution, audit, transport hardening, policy, inventory,
change control — everything that is not NETCONF or XML-API — lives here once
and is consumed by every vendor server.
Every mechub MCP server is built on it:
| Server | Vendor | Device transport | Latest release | Status |
|---|---|---|---|---|
rustjunosmcp |
Juniper Junos / SRX | NETCONF over SSH (rustnetconf) |
v0.27.5 | |
rustpanosmcp |
Palo Alto PAN-OS | HTTPS XML-API | v0.16.2 | |
rustproxmoxmcp |
Proxmox VE | HTTPS REST API | v0.11.2 | |
rustsdcmcp |
Juniper Security Director Cloud | HTTPS REST API | v0.3.1 | |
rustmistmcp |
HPE Juniper Mist | HTTPS REST API | v0.4.0 | |
rustunifimcp |
Ubiquiti UniFi Network | HTTPS REST API | v0.6.2 | |
rustfortimcp |
Fortinet FortiGate | HTTPS REST API | unreleased | |
rustopnsmcp |
OPNsense | HTTPS REST API | unreleased | |
mecfeedmcp |
Threat-intel feeds (PAN-OS EDL, SRX, FortiGate, OPNsense) | Firewalls pull a versioned HTTPS feed | unreleased |
Each server keeps only its vendor transport and tools; everything above the
device boundary comes from mecmcp, so a hardening fix lands once and reaches
every server on its next release. Status reflects honest maturity: no server is
tagged stable yet, and all run in the maintainer's lab, not production.
0.10.0 — operator waivers are a kind, bounded, and expire (#275).
Every waiver was a lab-mode waiver by construction — the digest bound the literal
string "lab-mode-waived", so a bounded, ticketed exception granted under a
control that is still on and someone switching the control off entirely
were recorded identically. reason was free text that nothing verified and
nothing bound.
The fix: WaiverKind (LabMode / OperatorFile / OperatorTool,
#[non_exhaustive]); expires_at_unix and ticket, both digest-bound;
compute_waiver_digest_v3, which hashes a serialized tuple with a
domain-separation marker instead of a |-joined string, so no field value can
shift a boundary; schema v3; a new waive_approval_operator; and expiry
enforced at apply, at both the pre-guard and post-guard gates.
Breaking: WaiverRecord struct-literal construction now needs kind (plus
the two new Option fields expires_at_unix and ticket), and validate_state
takes a version argument. Consumers that only call waive_approval need no change.
Checked across the consumer repos: none constructs a WaiverRecord, references
WaiverKind, or calls validate_state, so upgrading is a dependency bump with
no code changes. Bump both strings on each entry — a version = "0.9.x"
requirement does not accept 0.10.0, so changing only tag = "v0.9.1" leaves
the dependency unresolvable.
No data migration: a live survey of the Junos and PAN-OS lab guests, The SDC guest and both rehearsal rigs found 28 change sets and zero waiver records, so changing the waiver digest invalidates nothing that exists. The neighbouring approval digest was deliberately left alone for that reason and is tracked as #283. Any state file containing a waiver is now written as version 3; files with no waivers still select v1/v2 exactly as before.
0.9.1 — test consumers can serve a plan on an ephemeral loopback port (#280).
0.9.0 sealed the Router inside ServePlan, so serve_router became the only
way to serve one. Four consumer migrations worked around that by cloning the
listener token and driving serve_router in a background task, then racing their
client against the bind to discover the port from the OS. The harness extracts
that pattern: mecmcp_transport::test_harness::serve_on_loopback takes a
ServePlan and returns the bound SocketAddr, so the client can dial it
immediately. Loopback-only, as the control on inadmissible listeners is the
signature serve_router moved to the right of.
0.9.0 also warns when a revoke has not reached the server (#266).
token revoke removed the entry, printed revoked '<name>' and exited 0 —
while a running server kept its store in memory and went on accepting the
credential until signalled. Measured live: the token still returned 200
immediately after the revoke, and 401 only after SIGHUP. The caching stays,
deliberately — a failed reload must not take authentication offline — but
revoke and rotate now say plainly that the credential is not dead yet and
name --server-pid. Rotate needs it more than revoke: without the reload the
server still accepts the old secret. The exit code stays 0, because the
revoke did succeed.
0.9.0 — listener validation is unskippable (#273). mecmcp_runtime::cli_validate
refused two configurations that matter — unauthenticated off-loopback and
off-loopback with no Origin allowlist — but had no call site anywhere in the repo,
so a consumer could serve unauthenticated on the LAN and nothing failed. This is
the third instance of a class fixed in 0.8.1 (audit unskippable) and 0.8.3
(client_name correct but unwired).
The fix: authentication became a constructor choice (authenticated /
unauthenticated + NoAuthAcknowledgement), build_streamable_http_router
returns a ServePlan rather than a bare router, and serve_router refuses an
inadmissible listener before it binds. Four refusals exist —
UnauthenticatedOffLoopback, InsecureBindNotAcknowledged, AllowedHostRequired,
AllowedOriginRequired — each with the condition stated in the error message.
Loopback is exempt from all of them. --allow-no-auth does not permit an
off-loopback bind: serve_router refuses that regardless.
cli_validate remains a courtesy pre-check. It fails a bad CLI before anything
is constructed, and its messages name the flags rather than the transport
concepts. But it is no longer the control — skipping it costs a startup refusal
instead of an open port. The generic validate_with_origin_policy is gone,
folded into validate, which now always requires an Origin allowlist
off-loopback.
Breaking: consumers must migrate their main.rs. See Upgrading to 0.9.0 below.
mecmcp-transport 0.9.0:
-
Authentication as a constructor choice. The old builder flow is gone:
Before:
let config = HttpTransportConfig::new(identity, limits, host_origin, shutdown) .with_bearer(boundary);
After (authenticated):
let config = HttpTransportConfig::authenticated( identity, limits, host_origin, shutdown, boundary, );
After (unauthenticated):
let config = HttpTransportConfig::unauthenticated( identity, limits, host_origin, shutdown, NoAuthAcknowledgement::operator_allowed_no_auth(), );
-
build_streamable_http_routerreturnsServePlan, not(Router, HttpShutdown). The plan carries the router, the shutdown, and the policyserve_routermust check. Extract what you need from the plan before passing it toserve_router, which takes it by value:Before:
let (router, shutdown) = build_streamable_http_router(factory, config)?; serve_router(router, addr, tls, shutdown, timeout).await?;
After:
let plan = build_streamable_http_router(factory, config)?; // Clone the listener token before moving the plan (if wiring SIGTERM): let listener_token = plan.shutdown().listener().clone(); serve_router(plan, addr, tls, timeout).await?;
-
Off-loopback listeners now require
--allowed-origin. This is a behavior change: an empty Origin allowlist is currently valid and disables Origin checking by design. Fleet survey (2026-08-13) found exactly one affected deployment. The Junos lab guest (rust-junosmcp) binds0.0.0.0with--allowed-hostand no--allowed-origin, and will be refused at startup on 0.9.0. Add--allowed-originto its drop-in override before installing the 0.9.0 binary. It is taggedprotected: snapshot it first. The PAN-OS guests (rust-panosmcp) already pass an Origin allowlist and are unaffected; the Mist, SDC and rehearsal guests bind127.0.0.1and are exempt.
0.8.3 — 0.8.2's client name never actually reached anyone. Upgrade past 0.8.2.
The propagation was correct and unreachable. The middleware reads the captured
clientInfo out of BoundaryAccounting.session_tracker;
BoundaryAccounting::new leaves that None, and build_streamable_http_router
never called with_session_tracker. Every consumer goes through that assembly,
so every one of them kept emitting client_name="" exactly as before 0.8.2
shipped.
Found by bumping a consumer to 0.8.2 and reading a real audit event, not by any
test here. Nothing in this repo could see it: the middleware unit tests build a
BoundaryAccounting directly, and the end-to-end test attaches a tracker by
hand — both prove the middleware works when wired, neither observes the
assembly that does the wiring. The missing harness is tracked in #251.
Also fixes the workspace lint: cargo clippy --all-targets had been failing on
111 unwrap_used errors in test code, so every PR was red regardless of its own
diff.
Additive over 0.8.2. If you are on 0.8.2 for client_name, you need this.
0.8.2 — the MCP client name reaches the audit record (#53).
AgentIdentity.client_name existed but nothing populated it, so every audit
event emitted "client_name":"". MCP's initialize already carries
clientInfo, which needs no client cooperation beyond the standard handshake.
It is now captured onto the session and attached to the transport audit event
for every tools/call on that session.
The name is client-asserted and stays that way: it is never added to
token_verified_fields, so a caller's claim cannot be read back as
server-verified. Like the tool name, it arrives from a request body, so it is
interned into a bounded table — 64 entries — rather than leaked per distinct
value. An unknown session leaves the field empty; nothing is invented, and no
placeholder is substituted.
This identifies the client program, not the model and not the user.
claude-code tells you the request came from Claude Code and says nothing about
which model drove it. model_id and session_id remain unwired.
Additive over 0.8.1. Consumers get the field populated with no code change once they move their pin; nothing is required of them.
0.8.1 — audit by construction (#32), with the tool name bounded.
The bearer boundary now emits a transport audit event for every tools/call
before dispatch, so a tool is audited because it went through the transport, not
because someone remembered. Handlers still emit their own enriched event —
action, resolved device targets, outcome — correlated by request id; the
transport cannot know any of those. (The correlation described here did not
work until #269. Attribution::from_caller minted a fresh request id on every
call, so through 0.8.8 the two events for one request carried different ids and
could not be joined.)
The tool name is read from the request body, which is attacker-controlled and is
parsed before the preflight has decided whether the caller may call anything.
It is interned into a table capped at 256 entries of at most 128 bytes each,
restricted to [A-Za-z0-9_-]; anything else records as unregistered. An
earlier revision leaked every parsed name for the life of the process on the
reasoning that tool names are "a finite, small set" — true of the registry,
false of a request field, and both a memory-exhaustion and an
unbounded-cardinality path.
0.8.0 — extraction milestone 4: generic scope preflight + shared test client.
Four consumers (Junos, PAN-OS, SDC, Mist) had near-identical preflights differing
only in argument field names (Junos: router/routers, PAN-OS: device, SDC:
tenant, Mist: org/site). ToolScopePreflight is the generic implementation,
configured with TargetFields. Also added: test_client::McpClient, a shared
SSE-aware HTTP client for integration tests, replacing product-local helpers.
mecmcp-transport 0.8.0:
- New exports:
MalformedArgumentsPolicy,MalformedTargetPolicy,TargetValueShape,TargetField,ToolScopePreflight(#109–113, #142–147). Configure withTargetField::scalar("device")or custom shapes. - New
test_clientmodule:McpClientfor integration tests (#184). Handles initialize handshake, SSE parsing, and session ID tracking. - All workspace crates bumped to
0.8.0. Update path dependencies.
Breaking: None. All additions are additive. Existing preflight implementations continue to work.
0.7.3 — axum-server 0.8, dropping the unmaintained rustls-pemfile.
0.7.2 put axum-server into every consumer's tree, which brought
rustls-pemfile with it and tripped RUSTSEC-2025-0134 (unmaintained) in two
consumer pipelines. 0.8 drops that dependency outright, so the fix belongs here
rather than as an ignore entry repeated in each consumer. Its from_tcp and
from_tcp_rustls are now fallible; a failure there is reported as
HttpServeError::Bind.
0.7.2 — the drain can now actually deliver a response. 0.7.1 made SIGTERM
reach serve_router, but rmcp was still handed the same token as the
listener, so it ended every session the instant shutdown began — and an MCP
response travels back over its session's SSE stream. An in-flight call at
SIGTERM received 28 bytes of SSE preamble and nothing else, indistinguishable
from being dropped outright. rmcp now gets its own token, cancelled when the
drain deadline expires rather than when it starts.
Breaking: build_streamable_http_router returns (Router, HttpShutdown)
and serve_router takes that HttpShutdown — the two tokens must stay paired,
so they are one type rather than a bare CancellationToken. Note the
consequence: while any SSE stream is open, shutdown takes the full
shutdown_timeout. Keep it well under systemd's TimeoutStopSec.
0.7.1 — the 0.7.0 drain never fired. Upgrade past 0.7.0. ShutdownSignal's
Future impl rebuilt CancellationToken::cancelled() on every poll and dropped
it at the end of the poll, deregistering the waker it had just installed. Nothing
ever woke the task, so subscribe().await never completed and SIGTERM never
reached serve_router. Caught on a lab box, not in CI: the four existing unit
tests all wrapped the signal in tokio::time::timeout, whose own timer re-polled
the task at the deadline — by which point the token was already cancelled, so
they passed against a future that could not complete. The new test parks a task
on the signal with the token as its only wake source.
0.7.0 — extraction milestone 3: HTTP transport assembly. A consumer no
longer hand-assembles its router: HostOriginPolicy, HttpTransportConfig,
build_streamable_http_router and serve_router compose the whole protected
/mcp endpoint, and serve_router finally takes a shutdown signal so
systemctl restart drains in-flight calls instead of dropping them.
rustsdcmcp can delete compat/http.rs. Milestone 4 (preflight) remains.
streamable_http_server_configmerged the body-cap and host/origin concerns; the replacement is exported asbuild_rmcp_server_config.build_streamable_http_routerreturns(Router, CancellationToken). Pass that token toserve_router— the pairing is what keeps rmcp's SSE sessions and the listener draining on the same signal.GracefulShutdown::newreturnsResult, and now handles SIGTERM as well as SIGINT. The signal is latched, so one arriving before a subscriber attaches is still observed.HttpServeError::Servecarries the address that failed.
HostOriginPolicy has only an Enforced variant: there is deliberately no way
to disable the Host allowlist, which is the DNS-rebinding guard
(RUSTSEC-2026-0189). Note that Host and Origin treat a portless allowlist entry
differently, on purpose — a portless Host entry matches any port, because
--allowed-host 192.0.2.10 must keep working on :30031, while a portless
Origin entry matches only a portless browser Origin, because wildcarding there
would widen the policy.
0.6.1 — twelve crates; mecmcp-scp added. A native SCP1 file-transfer
client for devices that disable SFTP-over-SSH (Junos among them), so a server
can move files without spawning scp — which is what lets a consumer run on a
distroless image with no shell. Unix-only, gated at the module export. Additive:
nothing from 0.6.0 changed.
0.6.0 — eleven crates; extraction milestone 2 landed. The cloud-foundations
programme (#90) shipped mecmcp-secret, mecmcp-http, mecmcp-job and
mecmcp-openapi; mecmcp-changeset gained multi-target change sets;
mecmcp-server (milestone 1) and now the bearer boundary (milestone 2) replace
what consumers were reimplementing locally. Milestones 3 (HTTP transport
assembly) and 4 (preflight) remain.
Not published to crates.io. Consumers depend on this repository directly and pin an exact version.
Superseded in part by 0.7.0's transport assembly. This section documents the consumer applying the bearer boundary and the per-IP rate limit by hand, which was correct at 0.6.0. Since 0.7.0,
build_streamable_http_routerdoes all three itself — it applies the boundary, applies the IP rate limit, and builds theBoundaryAccounting. On 0.7.0 or later, do not apply them yourself: you get both layers twice, which silently halves the per-IP budget and stacks the boundary.The pattern below is the 0.6.0 pattern, kept for readers upgrading through this release. It is not a complete recipe for hand-assembling a router on 0.7.0+, and it is not an alternative to
build_streamable_http_router. Beyond the double-apply, it omits two things the builder installs:
- Host and Origin validation outside
/mcp.build_rmcp_server_configgives rmcp the Host allowlist, but that only guards the service nested at/mcp. The builder additionally layers this crate's own Host/Origin middleware over the entire router, which is what stops a DNS-rebinding attacker page — whose request lands on the victim's own loopback interface and so passes the/metricsloopback gate — from reading/metricswith a foreign Host header. That middleware is private and installed only bybuild_streamable_http_router. A hand assembly therefore keeps the Host guard on/mcpand loses it everywhere else — and loses Origin checking entirely, sincebuild_rmcp_server_configdeliberately empties rmcp'sallowed_originson the assumption that this crate will check it. (An emptyallowed_originsdisables the Origin check by design, builder or not; the loss bites a consumer who configured one.)- The session tracker. From 0.8.3 the builder wires it via
authenticated_accounting, which callswith_session_tracker.BoundaryAccounting::newalone leavessession_trackerasNone, and passing a tracker toConcurrencyStatedoes not populate the field the audit preflight reads — so a hand assembly on 0.8.3+ emits an emptyclient_name. This is the 0.8.3 defect: 0.8.2 shippedwith_session_trackerwithout the builder calling it. Releases before 0.8.2 had neither the field nor client-name propagation at all.Use
build_streamable_http_routeron 0.7.0+.
Extraction milestone 2: the bearer boundary. All 22 issues (#96, #97,
#103–#108, #118, #129–#141) land in this tag, because partial availability
would mean a consumer maintaining both paths. rustsdcmcp can now delete
compat/bearer.rs rather than adapt it.
New API. mecmcp-auth gains BearerSyntax, BearerHeaderError and
parse_bearer_header. mecmcp-transport gains BearerAuthenticator,
BearerBoundary, BearerResponseProfile, apply_bearer_boundary,
AuthenticatedToken and CallerScopes.
Breaking, in the order a consumer will hit them:
ScopePreflight::checktakesCallerScopes<'_>, not&CallerCtx. The scope-only view is what lets onedyn-safe implementation serveCallerCtx<G>for any grant type. Field access is identical; change the parameter type. All three servers implement this trait —rust-panosmcp/src/http_transport.rs,rust-junosmcp/src/http_transport.rs, andrustsdcmcp's compat copy.apply_bearer_boundarytakes aBoundaryAccounting, not a closure. The crate now owns the layer order rather than accepting an opaqueFnOnce(Router) -> Routerit cannot inspect.concurrency_middlewareis deprecated, split intotoken_concurrency_middleware(non-buffering, runs before the body limit) andtarget_concurrency_middleware(buffering, runs after authorization). Consumers passingBoundaryAccountingcall neither directly.apply_rate_limitis deprecated, split intoapply_ip_rate_limit(outside the boundary, before authentication) andapply_token_rate_limit(inside it, after authentication).- A 403's
WWW-Authenticateno longer carries the preflight reason. It is a fixederror="insufficient_scope", with the reason in the JSON body — a reason containing a quote corrupted the challenge, and a control character turned an intended 403 into a 500. BearerResponseProfilegainstry_detailed/try_compactreturningResult. Use those for a configuration value; the infallible constructors take&'static str.
The boundary builds everything from authenticate inwards, so a consumer cannot
get that part wrong. At 0.6.x, per-IP rate limiting was the one layer the
consumer still applied itself, because it must run before authentication — a
request with a missing, malformed or unknown token has no identity to charge, and
metering it is what stops an authentication flood.
Pre-0.7.0 only. At 0.7.0 and later build_streamable_http_router applies
both layers; running this by hand as well double-applies them. This snippet is
not the 0.7.0+ hand-assembly recipe either — see the note at the head of this
section for what it leaves out.
let limits = Arc::new(limits_config);
let accounting = BoundaryAccounting::new(
ConcurrencyState::new(&limits, target_keys, Some(session_tracker)),
Arc::clone(&limits),
);
let app = apply_bearer_boundary(app, boundary, accounting);
let app = apply_ip_rate_limit(app, &limits); // outermost: runs firstUse BoundaryAccounting::none() for a deployment with no per-token accounting.
Axum runs the last-applied layer first, so apply_ip_rate_limit must be applied
after apply_bearer_boundary to sit outside it. On a hand-assembled router,
omitting it leaves unauthenticated requests unmetered.
The resulting order — unchanged at 0.7.0+, where the router builder produces it rather than the consumer:
IP rate limit → authenticate → token rate limit → token concurrency
→ body limit → preflight → target concurrency → handler
Security fixes worth naming, all found by review rather than by tests:
- Per-token concurrency and session caps were bypassed for grant-bearing
callers —
concurrency_middlewarelooked upCallerCtx<NoGrant>while the boundary insertedCallerCtx<G>, and extension lookup is type-specific. - A preflight rejection cost the caller nothing, and an unauthenticated request charged no per-IP bucket, so both could be flooded for free.
- A token scoped to target A could acquire target B's concurrency permit before the scope check rejected it, starving authorized B traffic.
body_limitwas defeated whenever per-target concurrency buffered the request ahead of the cap.
Breaking for consumers only because of the rmcp major: rmcp 2 → rmcp 3.1.1.
Nothing in this workspace's own API changed shape. A consumer must bump its own
rmcp dependency in the same commit — cargo will not unify a 2.x and a 3.x rmcp,
so a partial upgrade is a build failure rather than a subtle one.
What the rmcp major actually is. rmcp 3.x implements the 2026-07-28 MCP
revision, but it is not the forced cutover the spec summaries suggest. The
initialize handshake and Mcp-Session-Id are not removed: rmcp runs both
protocols side by side and StreamableHttpServerConfig::legacy_session_mode
defaults to true, which is the pre-2026-07-28 flow. Statelessness is opt-in,
per request, keyed on the version the client declares.
Consumer-visible changes:
| Change | Action |
|---|---|
StreamableHttpServerConfig::stateful_mode renamed to legacy_session_mode |
Rename the field; same default (true), same behaviour |
SessionManager gained event_store() |
Defaulted, so nothing breaks — but see below if you wrap a manager |
Mcp-Method / Mcp-Name header validation |
Now enforced by the SDK for clients declaring >= 2026-07-28. No middleware needed |
StreamableHttpServerConfig::max_request_body_bytes is new, and defaults to 4 MiB |
Stop calling StreamableHttpServerConfig::default(). Call mecmcp_transport::streamable_http_server_config(&limits) instead. |
Everything else is source-compatible. This migration changed neither the MSRV (1.88 at the time; 1.89 today) nor the edition (2024) — the workspace already required both.
The body limit is the one that bites silently. rmcp 3 enforces its own 4 MiB
request-body cap inside the service, after this crate's apply_body_limit
layer has already accepted the request. LimitsConfig::max_request_body_bytes
defaults to 10 MiB here, so on a plain default() every request between 4 and
10 MiB starts failing with a 413 from a limit the operator never set and cannot
see in their own config. streamable_http_server_config derives it from
LimitsConfig and maps 0 (unlimited) to usize::MAX.
Superseded by 0.7.0. The
&limits-onlystreamable_http_server_configis#[deprecated]since 0.7.0, replaced bybuild_rmcp_server_config(&policy, &limits, shutdown), which took the Host allowlist and the shutdown token alongside the body cap. On 0.7.0 or laterbuild_streamable_http_routercalls it for you, so a consumer using the builder needs no call at all. Note that it populates rmcp'sallowed_hostsbut deliberately leavesallowed_originsempty — Origin is checked by this crate's own middleware, which only the builder installs, and only when an Origin allowlist is configured.
Session capacity is now protocol-aware. rmcp computes
use_session = legacy_session_mode && is_legacy_request(..), so a client
declaring 2026-07-28 is routed statelessly even though legacy_session_mode
is true — and a stateless POST carries no Mcp-Session-Id because there is no
session. The old classifier (POST without that header) would therefore have
charged every ordinary tools/call from a modern client against
--max-sessions and --max-sessions-per-token, handing out 503s after
max_sessions calls. is_session_creating now also requires the absence of an
MCP-Protocol-Version at or above 2026-07-28. No consumer action needed.
One security note on the new header validation. rmcp only enforces
Mcp-Method/Mcp-Name when the request declares protocol >= 2026-07-28; a
client declaring an older version simply omits them. So the headers are an
optimisation, not a control. The scope preflight remains the authorization
boundary — do not move an authorization decision up to a header a client can
decline to send by claiming to be old.
LimitedSessionManager overrode 8 of the 10 SessionManager methods and
inherited the defaults for restore_session (NotSupported) and, as of rmcp 3,
event_store (None). Because it is a wrapper, inheriting those defaults meant
reporting "restore unsupported, no event store" on behalf of the inner
manager, discarding whatever that manager actually provided.
Latent until now — both current consumers wrap LocalSessionManager, which
supplies neither — and it would have stayed silent when it did bite: the symptom
is SSE resumability quietly not working, not a compile error or a log line.
event_store is now forwarded. restore_session is now an explicit refusal
rather than an inherited one — same answer, stated on purpose.
That asymmetry is deliberate. Forwarding event_store touches no limit and
undoes a real rmcp 3 regression. Forwarding restore_session looked like the
same two-line change and is not: every limit this wrapper enforces is applied on
the create path, and a restore arrives by a different route.
- Per-token cap. The concurrency middleware classifies a request as
session-creating with
POST && !contains("mcp-session-id"). A restore necessarily carries that header, so no token slot is reserved. A restored session would raise only the global count — letting one token restore its old sessions and create a fullmax_sessions_per_tokenon top. - Idle and lifetime timeouts. The reaper closes the inner in-memory session
but cannot delete an entry from an rmcp
session_storeit does not own. A reaped id could be restored with fresh timestamps, repeatedly, which does not weaken the timeouts so much as remove them. - Overload response. A cap rejection on the restore path would surface as a
generic 500 rather than the stable 503 +
Retry-After.
Refusing costs nothing today — LocalSessionManager, which both consumers wrap,
returns NotSupported anyway. Doing it properly needs reaper tombstoning, a
token reservation on the restore path, generation-safe registration and
cancellation-safe cleanup; that is tracked separately. Until then an honest
refusal beats a cap that quietly does not hold.
Additive: one new crate, mecmcp-server, and nothing else changed. The minor
bump is because a new crate is new API, not because anything broke — a consumer
that does not want it can stay pinned at v0.3.9 indefinitely.
mecmcp-server is #199's milestone 1: the vendor-neutral helpers every MCP tool
handler in this family needs, which three servers were each carrying their own
copy of.
| Group | Items |
|---|---|
| Rendering a result | tool_result, tool_error, ResultFormat, ResultLimits, BoundedText, bounded_text |
| Authorizing a call | authorize_call, authorize_tool, authorize_target, AuthorizationError, caller_from_extensions, filter_tools_for_scope, audit_scope |
Two behaviours to know before adopting, both documented at the call site:
tool_resultrefuses an oversized success rather than truncating it. A caller handed a shortened value cannot tell it from a complete one.bounded_textis for the places that genuinely want a prefix.- A
Nonecaller is authorized for everything, because that is the stdio path, which has no bearer token. On an authenticated path a handler must pass the caller it recovered — passingNoneon a lookup miss authorizes the call.
This lets rustsdcmcp delete compat/server.rs and rustsdcmcp-core/src/compat.rs,
332 of its 1,162 compat lines. The remaining groups — bearer boundary, HTTP
transport assembly, preflight — are tracked in #199.
A correctness release: every finding from the codex review of 0.3.8's unreviewed window, nine P1 and twelve P2. Four changes need a consumer's attention.
| Change | What a consumer must do |
|---|---|
TokenStoreFile::set_scopes gained a grant: Option<G> parameter |
pass None to leave the stored grant alone — a NoGrant consumer always wants None. This is a plain arity change, so the compiler will find every call site |
TargetError gained MissingPrimary |
nothing unless you match the enum exhaustively; a target set must now contain the record's own device |
try_parse_from, parse_with_provenance and ParsedCli are generic over the consumer's parser |
nothing if you use parse_for; ParsedCli defaults to Cli. Servers with flags of their own should now flatten Cli into their own struct and pass that — the previous API could not parse a vendor flag at all |
ChangeSetRecord::validate_target_set and validate_preview are enforced at insert and load |
a preview's digest must be built with the new preview_digest; a hand-written value is refused |
Behaviour changes worth knowing about, none of which need code:
- A multi-target change set now survives a restart. It was written with the five-tuple digest and re-verified with the four-tuple, so every one was rejected on the next load. Single-target digests are unchanged, byte for byte.
max_targets_per_setandmax_preview_bytesare enforced. They were read by nothing before, so a deployment relying on the old non-enforcement would now see refusals.set-scopesasks for--yesin two more cases: any grant replacement, and a tool scope moving from*to an allowlist. Both are escalations — the tool wildcard deliberately withholds the server's write tools, so naming one grants what the wildcard withheld.- An expiry sweep no longer retires a change set whose apply is in flight, and it is persisted even when the insert that triggered it is refused.
init_tracingno longer drops the rotation handle when the consumer already installed aloglogger.
On-disk state is unchanged from 0.3.8 in both directions.
This release is breaking at the source level, and needs a deliberate upgrade rather than a version-string change:
| Change | What a consumer must do |
|---|---|
OperationLimits gained public fields |
struct-literal construction needs ..OperationLimits::default() |
ChangeSetRecord gained required targets and preview fields |
it has no Default, so ..Default::default() does not compile — add targets: Vec::new(), preview: None to every literal |
mecmcp-secret is now Unix-only |
nothing on Linux; the crate refuses to compile elsewhere by design |
mecmcp-auth and mecmcp-inventory read their files through the shared hardened loader |
tokens.json and devices.json must be mode 0600, a regular file, and owned by the service user — inventory had no such check before |
On-disk state is compatible in both directions only while no change set holds
targets or a preview. Either one gates the file to version 2, and 0.3.7's
ChangeSetRecord is deny_unknown_fields, so it rejects the whole file — the
absence of multi-target sets alone is not enough, because preview does it too.
A 0.3.8 deployment using neither still writes version-1 files that the previous
binary reads.
Verify the deployed files before rolling out. A tokens.json or
devices.json that has drifted to 0644 loaded fine under 0.3.7 and will be
refused at startup by 0.3.8.
| Document | What it is |
|---|---|
ANALYSIS.md |
Side-by-side teardown of both repos — what is duplicated, what is asymmetric, what stays vendor-specific |
PLAN.md |
Program-level extraction plan: crate map, phase sequencing, decisions, exit criteria (historical — the plan is delivered) |
ROADMAP.md |
What "enterprise grade" means at 150 engineers and 4,000 multi-vendor firewalls |
docs/ARCHITECTURE.md |
How mecmcp is put together and how a vendor server sits on top of it — the crate tiers and the request lifecycle |
docs/CRATE-MAP.md |
Start here for the whole picture. What each of the fourteen crates does, the dependency graph, and which crates each vendor server actually consumes — with diagrams |
docs/PACKAGING.md |
How a mechub MCP server is delivered and installed — container base, LXC, README requirements |
docs/AUDIT-FORWARDING-STANDARD.md |
Standard. How every server ships its audit trail off the host: JSON emission rules (normative) and the hash-chained ClickHouse sink (#292) |
docs/superpowers/plans/ |
Executable per-phase implementation plans |
| Crate | Responsibility | Extracted from |
|---|---|---|
mecmcp-auth |
Token mint/digest/verify, tokens.json load + hot-reload, scopes, grants, caller context |
both (union) |
mecmcp-audit |
Attribution, audit events, redaction, pluggable sinks | rustjunosmcp |
mecmcp-transport |
Streamable-HTTP: host/Origin checks, bearer middleware, rate limits, concurrency + session caps, metrics | rustjunosmcp limits/ + rustpanosmcp scope preflight |
mecmcp-runtime |
CLI skeleton, TLS bootstrap, signals, graceful shutdown | both (union) |
mecmcp-policy |
Compiled allow/deny rule engine over command and config subjects | rustjunosmcp policy.rs |
mecmcp-inventory |
Device registry as a trait — file today, database and NetBox later | both (generalized) |
mecmcp-device |
Connection lease/pool, per-device concurrency, cancellation, timeouts | rustjunosmcp |
mecmcp-changeset |
Plan → digest → approve → apply → verify, two-principal enforcement, indeterminate recovery | rustpanosmcp mutation.rs |
mecmcp-secret |
Outbound credential type (zeroizing, unprintable) and hardened env/file loader | new — see #90 |
mecmcp-job |
Cancellable job polling: immediate first probe, capped backoff, whole-operation deadline | new — see #90 |
mecmcp-http |
Outbound HTTP client: HTTPS-only, no redirects, no proxy, bounded concurrency, whole-request deadline, sensitive headers | new — see #90 |
mecmcp-openapi |
Whole-segment path expansion and bounded pagination — rejects, never clamps | new — see #90 |
mecmcp-intent |
Vendor-neutral policy/object model with per-vendor rendering | new — see ROADMAP |
A Streamable HTTP listener bound off-loopback must supply --allowed-host, and
an allowlist of blank strings does not count. An empty Host allowlist is not
"accept whatever the operator forgot to configure" — it is a DNS-rebinding and
Host-confusion surface.
--allowed-origin is required only by consumers whose transport actually
applies browser-Origin policy, via validate_with_origin_policy. Requiring it
everywhere refused a deployed server whose transport applies only allowed_host
— and any dummy value would have satisfied it while enabling nothing. That is
the failure docs/PACKAGING.md names: a flag that is present but ignored is
worse than one that is absent, because the operator cannot tell.
Loopback binds are exempt. A listener on 127.0.0.1 or ::1 is already bounded
by the host, and requiring the flags there would break every stdio and
local-HTTP deployment for nothing.
Vendor servers keep their protocol adapters, XML parsers, and vendor workflows.
For mecmcp-http specifically, that means endpoint catalogs, header names, payload
schemas, terminal job states, and retry policy stay in the product repository —
the shared crate owns only the transport posture.
An audit record that only exists on the machine that produced it is not an audit trail. Every server emits JSON to a file and forwards it to the security event store.
Transport is a direct, hash-chained write into ssdf.audit, per SSDF's
merged evidence contract — not syslog. Every other link in this chain is
tamper-evident by construction: plan digests bind approvals, approvals name a
distinct principal, token_verified_fields separates vouched-for provenance from
asserted. An unchained final hop would discard that guarantee at the point an
auditor relies on it.
Emission rules are normative today; the sink is tracked in
#292. The standard —
including why the cheaper syslog path was rejected — is in
docs/AUDIT-FORWARDING-STANDARD.md.
Licensed under MIT.