Skip to content
Merged
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
5 changes: 4 additions & 1 deletion deploy/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ managed VPC) and you just want to put the app on it. The repo's
```
build.sh ─────► build-out/imcp2 (cross-built linux/arm64 or amd64 binary)
deploy.sh ─────► /opt/imcp2/{imcp2,static} + systemd: imcp2.service
/opt/imcp2/monitoring + systemd: imcp-status.service (dashboard)
/opt/imcp2/monitoring (staging copy of the dashboard source)
/opt/imcp-status + systemd: imcp-status.service (dashboard;
root-owned executable copy, run as the
dedicated imcp-status user)
/usr/local/bin/caddy + systemd: caddy.service (TLS)
```

Expand Down
20 changes: 20 additions & 0 deletions deploy/native/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,26 @@ node_major="\$(node -v 2>/dev/null | cut -c2- | cut -d. -f1)"
if [ -z "\$node_major" ] || [ "\$node_major" -lt 20 ] 2>/dev/null; then
dnf install -y -q nodejs20 >/dev/null 2>&1 || dnf install -y -q nodejs >/dev/null 2>&1 || true
fi
# Dedicated UID for the dashboard: it can carry a Statuspage API key in its
# environment, so it must not share a user with the internet-facing imcp2
# service (same-UID processes can read each other's /proc/<pid>/environ).
id imcp-status >/dev/null 2>&1 || useradd --system --no-create-home --shell /sbin/nologin imcp-status
Comment thread
aterga marked this conversation as resolved.
Resolved
# Root-only directory for the optional Statuspage credentials
# (/etc/imcp-status/statuspage.env, mode 600 — see the mcp-status README).
# Provisioned here so the documented setup step works on a fresh host; systemd
# reads the EnvironmentFile as root, so no other user needs access.
install -d -m 700 -o root -g root /etc/imcp-status
# Execute a ROOT-OWNED copy of the dashboard source, not the staged copy under
# $REMOTE_DIR: the staging tree is owned (and writable) by the deploy/SSH user,
# which is also the UID the internet-facing imcp2.service runs as — a
# compromised main service could edit server.js there and have the next
# dashboard restart run its code in the secret-bearing service. Refresh the
# copy on every deploy; strip group/other write (files stay world-readable, so
# the unprivileged imcp-status user can run them).
rm -rf /opt/imcp-status
cp -a $REMOTE_DIR/monitoring/mcp-status /opt/imcp-status
chown -R root:root /opt/imcp-status
chmod -R go-w /opt/imcp-status
cat > /etc/systemd/system/imcp-status.service <<'UNIT'
$unit_status
UNIT
Expand Down
21 changes: 18 additions & 3 deletions deploy/native/imcp-status.service
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,31 @@ Wants=network-online.target

[Service]
Type=simple
User=ec2-user
WorkingDirectory=/opt/imcp2/monitoring/mcp-status
# Dedicated system user (created by deploy.sh), NOT the ec2-user the
# internet-facing imcp2.service runs as: this process can carry a Statuspage
# API key in its environment, and same-UID processes can read each other's
# /proc/<pid>/environ — a compromise of the main service must not be able to
# recover that secret. For the same reason the unit executes the ROOT-OWNED
# copy deploy.sh installs at /opt/imcp-status, not the ec2-user-writable
# staging tree under /opt/imcp2/monitoring: code writable by the main
# service's UID would hand the secret to whoever can modify it.
User=imcp-status
Comment thread
aterga marked this conversation as resolved.
Resolved
WorkingDirectory=/opt/imcp-status
# Probe the public deployment end-to-end (through Caddy/TLS). Which Internet
# Identity instances to probe is read from this server's own /version, so no II
# origin is configured here. The allowlist is pinned to the deployment's own host
# (the dashboard always permits the built-in id.ai suffixes on top), so the probes
# can never be steered elsewhere -- including by the server's own /version.
# Binds to 127.0.0.1; Caddy publishes it under https://<domain>/status/.
Environment=MCP_STATUS_ALLOWED_HOSTS=__ALLOWED_HOSTS__
ExecStart=/usr/bin/node /opt/imcp2/monitoring/mcp-status/server.js --port 8137 --host 127.0.0.1 --mcp https://__DOMAIN__
# Optional: mirror the verdict to an Atlassian Statuspage component (e.g. on
# status.internetcomputer.org). The file holds STATUSPAGE_PAGE_ID,
# STATUSPAGE_API_KEY and STATUSPAGE_COMPONENT_ID (see the mcp-status README).
# It is created by the operator (mode 600, owner root), never by deploy.sh, so
# the API key stays out of this repository and of the rendered unit file. The
# leading "-" makes it optional: without the file the pusher never starts.
EnvironmentFile=-/etc/imcp-status/statuspage.env
Comment thread
aterga marked this conversation as resolved.
Resolved
ExecStart=/usr/bin/node /opt/imcp-status/server.js --port 8137 --host 127.0.0.1 --mcp https://__DOMAIN__
Restart=always
RestartSec=5
LimitNOFILE=65536
Expand Down
57 changes: 57 additions & 0 deletions monitoring/mcp-status/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,63 @@ the host and runs it as the `imcp-status.service` systemd unit (bound to
publishes it at `https://<domain>/status/`, and the CI workflow runs the unit
tests below before rolling out. See the deploy README for details.

### Publishing to an Atlassian Statuspage (status.internetcomputer.org)

`server.js` can mirror the dashboard's overall verdict to one component on an
Atlassian Statuspage — the mechanism that puts MCP health on
[status.internetcomputer.org](https://status.internetcomputer.org/), which is
hosted SaaS whose components cannot be served from a repository, only driven
through its [REST API](https://developer.statuspage.io/). The pusher
(`statuspage.js`) is **off by default** and starts only when all three of these
are set:

| Variable | Value |
| -------- | ----- |
| `STATUSPAGE_PAGE_ID` | The page id (visible in the Statuspage admin, or as `page.id` in `https://<page>/api/v2/status.json`). |
| `STATUSPAGE_API_KEY` | An API key minted in the Statuspage admin (read from the environment, never argv; never logged). |
| `STATUSPAGE_COMPONENT_ID` | The id of the component to drive. |

One-time setup in the Statuspage admin: create the component (e.g. **"ICP MCP
server"**, optionally inside an existing group), mint an API key, and note the
page and component ids. On a deployed host, put the three variables in
`/etc/imcp-status/statuspage.env` (mode `600`, owner root) — the systemd unit
loads that file if present — and `systemctl restart imcp-status`.

The mapping is intentionally coarse (a public status page is not the detailed
dashboard):

| Dashboard verdict | Statuspage component status |
| ----------------- | --------------------------- |
| `pass` | `operational` |
| `warn` | `degraded_performance` |
| `fail`, but some service-availability checks still pass | `partial_outage` |
| `fail` with every service-availability check failing | `major_outage` |

"Service-availability checks" are the endpoint probes that report `fail` on a
failed request (landing page, discovery documents, the `/mcp` challenge, and
the OAuth endpoints). Auxiliary checks are excluded from the major-outage
test on purpose: `version` and `metadata-consistency` only degrade to `warn`
when the server is unreachable, and the TLS check can stay green while the
application behind the proxy is down — counting them would make a total
outage report as merely partial.

The pusher re-evaluates every `STATUSPAGE_PUSH_INTERVAL_MS` (default 60 s,
floor 15 s) and PATCHes the component **only when the mapped status changes**
(plus once at startup, since a restarted process cannot know the remote state)
— so a healthy steady state costs one API call per process lifetime. A failed
push is logged and retried on the next interval; it never affects the dashboard
itself.

Unlike visitor-triggered probe runs, the pusher's own runs are
**non-mutating**: it reuses a recent visitor-triggered report when one exists,
and otherwise probes with both registration checks skipped. The loopback
Dynamic Client Registration probe changes server state on every run (it mints
and persists a client_id in the server's LRU-bounded registration store), and
the allow-list probe would do the same whenever the server's guard regressed —
the very failure it detects — so an unattended periodic loop runs neither.
Both probes are still exercised by every interactive dashboard visit and CLI
run.

## Why a standalone tool (and not a page in the II frontend)?

The II frontend is a **static, prerendered, `ssr: false` SvelteKit app** served
Expand Down
36 changes: 31 additions & 5 deletions monitoring/mcp-status/checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,22 @@ export const parseCspDirective = (csp, directive) => {
/**
* @param {string} mcpOrigin
* @param {number} timeoutMs
* @param {{ mutating?: boolean }} [opts] With `mutating: false`, skip both
* registration probes. The loopback Dynamic Client Registration probe mints
* and persists a fresh client_id in the server's LRU-bounded registration
* store on every run. The allow-list probe stores nothing while the server's
* guard holds — but if that guard ever regresses (the exact failure it
* detects), each run would mint a client too, turning an unattended periodic
* caller into a store-grinding loop precisely when the server is broken.
* Interactive uses (a person loading the dashboard, a one-off CLI run) keep
* the default and still exercise both probes.
* @returns {Promise<{ section: Section, facts: Record<string, unknown> }>}
*/
export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => {
export const checkMcpEndpoints = async (
mcpOrigin,
timeoutMs,
{ mutating = true } = {},
) => {
/** @type {CheckResult[]} */
const checks = [];
/** @type {Record<string, unknown>} */
Expand Down Expand Up @@ -397,7 +410,10 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => {
// with a LOOPBACK redirect: loopback is always permitted (the hosted-redirect
// allow-list exempts it), so this is the dependency-free way to confirm a
// client can self-register without manual approval, which is what DCR is for.
{
// Skipped in non-mutating mode: this is the one probe that changes server
// state (the minted client_id is persisted in the LRU-bounded registration
// store), so an unattended periodic caller must not run it.
if (mutating) {
const url = `${mcpOrigin}/mcp/oauth/register`;
const r = await probe(url, {
timeoutMs,
Expand Down Expand Up @@ -441,7 +457,11 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => {
// uses a reserved `.invalid` host (RFC 2606) that can never be legitimately
// allow-listed by a deployment, so this can't false-alert if the allow-list
// is widened via OAUTH_ALLOWED_REDIRECT_PREFIXES.
{
// Also skipped in non-mutating mode: while the guard holds this stores
// nothing, but if it regressed (exactly what this probe detects) each run
// would mint a client — an unattended periodic caller must not become a
// store-grinding loop at the very moment the server is broken.
if (mutating) {
const url = `${mcpOrigin}/mcp/oauth/register`;
const r = await probe(url, {
timeoutMs,
Expand Down Expand Up @@ -1003,13 +1023,19 @@ export const buildSuggestions = (sections, facts) => {

/**
* Run the full dashboard against the resolved configuration.
* @param {{ mcpOrigin?: string, iiOrigin?: string, timeoutMs?: number }} [overrides]
* @param {{ mcpOrigin?: string, iiOrigin?: string, timeoutMs?: number, mutating?: boolean }} [overrides]
* `mutating: false` skips both registration probes — the loopback Dynamic
* Client Registration check (always mutates) and the allow-list check
* (mutates whenever the guard it tests has regressed) — see
* checkMcpEndpoints. The report then carries neither of those checks.
* @returns {Promise<DashboardReport>}
*/
export const runDashboard = async (overrides = {}) => {
const cfg = resolveConfig(overrides);

const endpoints = await checkMcpEndpoints(cfg.mcpOrigin, cfg.timeoutMs);
const endpoints = await checkMcpEndpoints(cfg.mcpOrigin, cfg.timeoutMs, {
mutating: overrides.mutating !== false,
});

// Which IIs to probe. Normally the ones the server advertises at /version —
// it is the authority on its own pairing, and it lists every mount, so both a
Expand Down
66 changes: 66 additions & 0 deletions monitoring/mcp-status/checks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,72 @@ test("checkMcpEndpoints passes for a well-behaved server", async () => {
}
});

test("checkMcpEndpoints in non-mutating mode never registers a client", async () => {
const origin = "https://mcp.beta.test";
/** @type {string[]} */
const registerRedirects = [];
const restore = stubFetch({
[`GET ${origin}/`]: resp(200, { headers: { "content-type": "text/html" } }),
[`GET ${origin}/version`]: resp(200, {
headers: { "content-type": "application/json" },
body: JSON.stringify({ version: "0.1.0", commit: "abc123def4567890" }),
}),
[`GET ${origin}/.well-known/oauth-protected-resource`]: resp(200, {
headers: { "content-type": "application/json" },
body: JSON.stringify({
authorization_servers: [`${origin}/mcp`],
resource: `${origin}/mcp`,
}),
}),
[`GET ${origin}/.well-known/oauth-authorization-server`]: resp(200, {
body: JSON.stringify({
issuer: `${origin}/mcp`,
authorization_endpoint: `${origin}/mcp/oauth/authorize`,
token_endpoint: `${origin}/mcp/oauth/token`,
registration_endpoint: `${origin}/mcp/oauth/register`,
code_challenge_methods_supported: ["S256"],
}),
}),
[`POST ${origin}/mcp`]: resp(401, {
headers: {
"www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`,
},
body: JSON.stringify({ error: "invalid_token" }),
}),
[`POST ${origin}/mcp/oauth/register`]: (init) => {
registerRedirects.push(
(JSON.parse(init.body ?? "{}").redirect_uris ?? [])[0] ?? "",
);
return registerRoute(init);
},
[`GET ${origin}/mcp/oauth/authorize`]: resp(400, {
body: "missing client_id",
}),
[`POST ${origin}/mcp/oauth/token`]: resp(400, {
body: JSON.stringify({ error: "invalid_grant" }),
}),
});
try {
const { section } = await checkMcpEndpoints(origin, 2000, {
mutating: false,
});
// Both registration probes are skipped: the loopback DCR probe always
// mints, and the allow-list probe would mint whenever the server's guard
// regressed — the rest of the suite still runs.
assert.equal(byId(section, "oauth-register"), undefined);
assert.equal(byId(section, "oauth-register-allowlist"), undefined);
assert.equal(byId(section, "root").status, "pass");
assert.equal(byId(section, "oauth-token").status, "pass");
assert.equal(
registerRedirects.length,
0,
"no registration request of any kind may be sent in non-mutating mode",
);
} finally {
restore();
}
});

test("checkMcpEndpoints flags a missing OAuth challenge", async () => {
const origin = "https://mcp.beta.test";
const restore = stubFetch({
Expand Down
34 changes: 34 additions & 0 deletions monitoring/mcp-status/log.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Shared log sanitisation for the IMCP status dashboard.
//
// Extracted from server.js so statuspage.js can reuse it instead of carrying
// a near-identical copy.

/**
* Reduce a value (an Error or anything else) to a single safe log line:
* message only (no stack), capped in length, with control characters replaced
* by spaces so that a logged error message can never forge or inject
* additional log entries. Covers C0 controls + DEL, the C1 controls
* (U+0080–U+009F, incl. U+009B the 8-bit CSI), and the Unicode line/paragraph
* separators (U+2028/U+2029). Implemented with a codepoint filter to avoid
* embedding control-char literals.
*
* @param {unknown} value
* @returns {string}
*/
export const sanitizeForLog = (value) => {
const input = String(
(value && /** @type {any} */ (value).message) || value,
).slice(0, 300);
let out = "";
for (const ch of input) {
const code = /** @type {number} */ (ch.codePointAt(0));
const dangerous =
code < 0x20 ||
code === 0x7f ||
(code >= 0x80 && code <= 0x9f) ||
code === 0x2028 ||
code === 0x2029;
out += dangerous ? " " : ch;
}
return out;
};
41 changes: 41 additions & 0 deletions monitoring/mcp-status/log.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Unit tests for the shared log sanitiser.
// Run with: node --test monitoring/mcp-status/log.test.js
// or: cd monitoring/mcp-status && npm test

import { test } from "node:test";
import assert from "node:assert/strict";
import { sanitizeForLog } from "./log.js";

test("sanitizeForLog strips control characters and separators", () => {
// C0 (LF, CR, ESC), DEL, a C1 control (the 8-bit CSI), and the Unicode line
// and paragraph separators - each must become a plain space. Built from
// codepoints so the test source embeds no control-char literals (same
// rationale as the implementation).
const ctl = (cp) => String.fromCodePoint(cp);
const input = [
"a",
ctl(0x0a),
"b",
ctl(0x0d),
"c",
ctl(0x1b),
"d",
ctl(0x7f),
"e",
ctl(0x9b),
"f",
ctl(0x2028),
"g",
ctl(0x2029),
"h",
].join("");
assert.equal(sanitizeForLog(input), "a b c d e f g h");
});

test("sanitizeForLog caps the length at 300 characters", () => {
assert.equal(sanitizeForLog("x".repeat(500)).length, 300);
});

test("sanitizeForLog prefers an Error's message and never its stack", () => {
assert.equal(sanitizeForLog(new Error("boom")), "boom");
});
Loading
Loading