Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio-util = "0.7"
uuid = { version = "1", features = ["v4"] }
tower-http = { version = "0.7", features = ["cors"] }
# Metrics exposition at /metrics. Pinned to the version dfinity/ic uses: these
# series are destined for the same Prometheus / Victoria Metrics clusters that
# scrape the IC, so matching the org's client keeps one exposition dialect in the
# estate. The `process` feature adds CPU / RSS / file-descriptor collectors on
# Linux, which is the deploy target.
prometheus = { version = "0.14", features = ["process"] }
sha2 = "0.11.0"
base64 = "0.22.1"
hex = "0.4.3"
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates
COPY --from=build /app/target/release/imcp2 /usr/local/bin/imcp2
# Static assets (signing frontend + WASM codec) are served relative to the workdir.
COPY static ./static
ENV RUST_LOG=info
# See deploy/native/imcp2.service: the per-request log line is debug-level, and
# is worth keeping on a deployed host.
ENV RUST_LOG=info,imcp2::metrics=debug
# PaaS injects $PORT; the server honours it (default 8000). PUBLIC_URL must be set
# to the deployment's public https URL so OAuth discovery + the /app link are correct.
CMD ["imcp2"]
15 changes: 15 additions & 0 deletions deploy/native/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ __DOMAIN__ {
reverse_proxy 127.0.0.1:8137
}

# Prometheus exposition is NOT published to the public internet. The app serves
# /metrics on 0.0.0.0:8000, so a scraper reaches it directly on the host's
# private address over the VPN — the same path the deploy already uses — and
# nothing needs to be exposed here to make that work.
#
# Returning 404 rather than 403 so the endpoint is not advertised as existing.
# Metrics are not secret, but they are a free operational read: request volumes
# and error rates by route, live session counts, process memory. That is
# reconnaissance for anyone probing the service, and a public scrape target is
# also an amplification lever, since each request makes the process gather and
# encode its whole registry.
handle /metrics {
respond 404
}

# Everything else is the MCP server.
handle {
# Retry the upstream for a few seconds if the dial fails, so the ~1-3s gap
Expand Down
6 changes: 5 additions & 1 deletion deploy/native/imcp2.service
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ User=ec2-user
WorkingDirectory=/opt/imcp2
Environment=PORT=8000
Environment=PUBLIC_URL=__PUBLIC_URL__
Environment=RUST_LOG=info
# The per-request line moved to debug so it does not drown `info` for embedders
# and local runs. On a deployed host that line is the record of what external
# clients actually probe, so keep it: enable debug for that target only, rather
# than turning the whole crate to debug.
Environment=RUST_LOG=info,imcp2::metrics=debug
# Also serve the beta II instance at /mcp-beta on STAGING only. deploy.sh
# substitutes __MCP_SERVE_BETA__ per environment: `1` on staging, empty on
# production. An empty value reads as off, so production serves /mcp (production
Expand Down
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@

mod auth;
mod calls;
/// Prometheus instrumentation, usable by embedders as well as by the bundled
/// binary. Exports the [`metrics::Metrics`] handle and the two request
/// middlewares.
pub mod metrics;
mod discover;
mod identities;
mod management;
Expand Down
118 changes: 99 additions & 19 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,6 @@ fn serve_beta() -> bool {
.unwrap_or(false)
}

/// Log each inbound request: method, path, response status, and latency — gives
/// visibility into what external MCP clients probe (discovery URLs, unknown
/// paths) at `RUST_LOG=info`. The query string is never logged (defense in depth,
/// keeping any single-use `?code=` out of logs) — and request bodies are never
/// logged (the redeem POST carries the connection-scoped `state` and delegation).
async fn log_request(
req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> axum::response::Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let started = std::time::Instant::now();
let resp = next.run(req).await;
let status = resp.status().as_u16();
let elapsed_ms = started.elapsed().as_millis() as u64;
tracing::info!(%method, %path, status, elapsed_ms, "http request");
resp
}

/// The landing page served at `/`: a self-contained design bundle exported from
/// Claude Design (`assets/index.html`, compiled in via `include_str!`, no
Expand Down Expand Up @@ -298,6 +280,27 @@ async fn main() -> anyhow::Result<()> {
// gauge reports zero for it when the staging instance isn't served.
let (ver_prod, ver_beta) = (prod.clone(), beta.clone());

// Metrics registry. Built once; the handle is cloned into the middleware and
// the /metrics route. A failure here means duplicate collector names, i.e. a
// programming error, so surface it at startup rather than serving a
// half-registered endpoint.
// This binary is the standalone case, so it owns the registry. An embedder
// passes its own instead; see imcp2::metrics.
let registry = prometheus::Registry::new();
let metrics = imcp2::metrics::Metrics::new(
&registry,
env!("CARGO_PKG_VERSION"),
option_env!("GIT_SHA").unwrap_or("unknown"),
started_at,
)?;
// CPU / RSS / file descriptors. Registered here rather than by the library:
// `process_*` describes the whole OS process, which belongs to the
// application, and this binary *is* the application.
imcp2::metrics::register_process_collector(&registry)?;
// The session gauges are read at scrape time, so /metrics needs the same
// handles /version uses.
let (met_prod, met_beta) = (prod.clone(), beta.clone());

// Which II each served mount hands off to. Built once (fixed for the process)
// and cloned per request. This is the only way an external monitor can learn
// the pairing: neither the mount path nor the origin implies it —
Expand Down Expand Up @@ -379,6 +382,74 @@ async fn main() -> anyhow::Result<()> {
}
}),
)
// Prometheus exposition. Unauthenticated like /version; see the note in
// deploy/native/Caddyfile on why this path is not published publicly.
.route(
"/metrics",
get({
let metrics = metrics.clone();
move || {
let metrics = metrics.clone();
let met_prod = met_prod.clone();
let met_beta = met_beta.clone();
async move {
// Refresh the derived gauges from the authoritative
// session maps before encoding. Beta reports zero when the
// staging instance is not served, so the series exists
// continuously rather than appearing and vanishing with
// the deployment shape — a gap in a gauge is much harder
// to reason about than a flat zero.
let p = met_prod.session_gauges().await;
metrics.set_sessions("prod", p.live as i64, p.active as i64);
let (b_live, b_active) = match &met_beta {
Some(b) => {
let g = b.session_gauges().await;
(g.live as i64, g.active as i64)
}
None => (0, 0),
};
metrics.set_sessions("beta", b_live, b_active);

let started = std::time::Instant::now();
let encoded = {
use prometheus::Encoder;
let mut buf = Vec::new();
prometheus::TextEncoder::new()
.encode(&registry.gather(), &mut buf)
.map_err(|e| e.to_string())
.and_then(|()| {
String::from_utf8(buf).map_err(|e| e.to_string())
})
};
metrics.observe_scrape(started.elapsed().as_secs_f64());
match encoded {
Ok(body) => (
axum::http::StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
body,
),
// A scrape failure must not be silent: Prometheus
// reads a non-200 as the target being down, which is
// the honest reading.
Err(e) => {
tracing::error!(error = %e, "failed to encode metrics");
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
String::from("failed to encode metrics\n"),
)
}
}
}
}
}),
)
// `nest_service`, not `nest`: it also forwards the bare trailing-slash
// form (`/mcp/`), which axum's `nest` never routes into the nested router.
.nest_service(prod.mcp_path(), prod.mcp_router())
Expand Down Expand Up @@ -410,7 +481,16 @@ async fn main() -> anyhow::Result<()> {
// what external clients actually hit — discovery probes, unknown paths,
// etc. Only the path is logged, never the query string, so single-use
// secrets (`?code=`) don't land in logs.
.layer(axum::middleware::from_fn(log_request));
// Two layers rather than one. They have different constraints — metrics
// must bound every label, a log line is more useful carrying the full
// path — and splitting them lets an embedder take either independently.
.layer(axum::middleware::from_fn_with_state(
metrics.clone(),
imcp2::metrics::write_request_metrics,
))
.layer(axum::middleware::from_fn(
imcp2::metrics::write_request_logs,
));

let bind = bind_address();
let listener = tokio::net::TcpListener::bind(&bind).await?;
Expand Down
Loading
Loading