A minimal, security-hardened example of a Mastra agent calling the
k9 Security MCP server's score_risk tool (reachability-aware vulnerability triage) over
OAuth 2.0. Use it as a starting point for calling score_risk from your own agents.
The recommended path is OAuth 2.0 client-credentials: provision a service client, exchange
its client_id / client_secret for a claim-bearing token, and call the tools. No human, no
browser, no refresh token. The interactive device flow stays as a fallback for quick
evaluation. Both are validated end-to-end against k9 Security.
- Node.js >= 24 (runs the TypeScript directly via native type-stripping; no build step,
no
tsx/esbuild). - An active k9 Reachable Risk entitlement on your k9 account (trial or paid);
score_riskis paywalled. - A service client for the recommended path: in the k9 app, open your customer → Service
Clients (M2M) and copy its
client_id+ one-timeclient_secret.
cp .env.example .env # set K9_CLIENT_ID + K9_CLIENT_SECRET
npm install --ignore-scripts # builds the lockfile under the .npmrc before-floor
npm ls easy-day-js # MUST be empty
npm audit signatures # verify registry signatures
# commit package-lock.json, then use `npm ci` for all later installsPinned to clean pre-incident Mastra releases; do not loosen the pins or .npmrc without
re-vetting (see Security posture).
Two modes; the example picks based on the environment:
- Client-credentials (recommended, unattended). Set
K9_CLIENT_ID+K9_CLIENT_SECRETfrom your service client. The example mints a claim-bearing token automatically and re-mints on expiry: nologin, no browser, no refresh token. Keep the secret out of source and history. - Device flow (interactive fallback). With no client-credentials set, run
npm run loginonce to approve in the browser as your k9 account; the example then uses and refreshes that token.
# client-credentials: set K9_CLIENT_ID / K9_CLIENT_SECRET in .env, then:
npm run list-tools # connect + list k9 tools (no LLM needed)
npm run score # call score_risk on a sample finding (no LLM needed)
npm run score-batch # score several findings in ONE call (connect amortized over the batch)
# device-flow fallback only:
npm run login # one-time browser approval; caches the tokenscore-batch scores multiple findings in a single score_risk call (the tool takes
findings[], up to 50). A representative run:
connect + listTools: 12355 ms | score_risk (3 findings, 1 call): 757 ms
log4shell-prod-reachable CVE-2021-44228 -> FIX_TODAY (KEV + reachable + tier-1/confidential)
requests-not-loaded CVE-2026-25645 -> DEFER (not loaded)
requests-reachable CVE-2026-25645 -> SCHEDULE (same CVE, reachable, tier-2)
Two takeaways. The ~12s connect (a per-session cold start the first time the k9 server is hit
after idle) is paid once while the 3-finding batch scores in <1s, so batch findings into
one call (and reuse the client for a long-running agent). And the same CVE flips DEFER to
SCHEDULE purely on reachability and asset context: reachability is the lever, not the CVE alone.
Cached tokens live under ~/.cache/k9-mcp-example/ (mode 0600): <stage>-cc.json for
client-credentials (re-minted on expiry), <stage>.json for the device flow (refreshed, with
rotation persisted).
Mastra's npm scope was compromised on 2026-06-17 (the easy-day-js incident). This project
is pinned to the last clean pre-compromise releases and layered with controls so a poisoned
version cannot be installed. Do not loosen the pins or the .npmrc without re-vetting.
Details and sources: SECURITY.md.
| File | Role |
|---|---|
src/config.ts |
per-stage endpoints + device client + client-creds env |
src/clientCredentials.ts |
M2M client-credentials token (recommended) |
src/deviceAuth.ts |
device flow + refresh |
src/tokenStore.ts |
token cache + auto-mint/refresh (both modes) |
src/login.ts |
one-time interactive login (device flow) |
src/mcp.ts |
Mastra MCP client wiring |
src/listTools.ts / src/score.ts |
no-LLM validations (list tools, single score) |
src/scoreBatch.ts |
batch scoring in one call (connect amortized) |
- Tools accessor is
listTools(), notgetTools()(the latter isn't in this version). connectTimeoutis required. Mastra's default connect timeout is 3s, too short for the k9 server's first request after it scales up from idle (cold start).mcp.tssets 30s.- One client per run. The bearer header is fixed at client construction and access tokens
live ~1h, so the example builds one client per CLI run with a fresh token. A long-running
process should rebuild the client (and
disconnect()the old one) when the token nears expiry; a static header won't pick up a new token on its own.
- See the k9 MCP M2M quickstart (provided by k9 Security) for the full
score_riskcontract and troubleshooting.