Skip to main content
Glama
shigechika

keycloak-mcp

by shigechika

spray_check

Analyze Keycloak login events to flag password-spray IPs and identify breached accounts, distinguishing real sprays from shared egress.

Instructions

Detect password-spray sources and name the accounts they breached — one rule, one call.

For every EXTERNAL source IP (anything outside the ranges declared in KEYCLOAK_SITES_INI) seen in LOGIN / LOGIN_ERROR events during the last hours, compute distinct users and success rate. An IP is a spray source when unique_users >= min_users AND success_rate < max_success_rate. Its successful logins are the breach CANDIDATES; whether they may be called breached depends on the row's confidence.

The breach list is built ONLY from LOGIN events whose source IP is the flagged IP, inside the window. Every entry carries the evidence tuple {time, ip, username, user_id, client_id}. A compromised account can therefore never be reported without an actual login event from the spray source — do not add names that are not in spray[].breached.

confidence separates a spray from a shared egress (school NAT, home line, VDI) that merely looks like one by volume. It is "low" — treat the successes as "verify with the owner", never publish them as breached — when any of these signals holds: user_success_rate (distinct users that logged in at least once ÷ distinct users) >= max_user_success_rate (real sprays sit at 0.0–0.06; a school NAT with students retyping a mistyped domain sat at 0.47), or failure_concentration (share of failures on the single most-failing username) >= max_failure_concentration (one locked-out user retrying from a shared line produced 0.91), or the IP is in KEYCLOAK_KNOWN_EGRESS. Only confidence: high rows are a breach verdict. Read top_failed_users and not_found_domains (domains of usernames that do not exist — typos of the real domain are humans, not a scraped list) before writing anything up.

Returns a fixed-shape dict: window: {hours, since, until} actually scanned. complete: False if event pagination was cut short (KEYCLOAK_DEADLINE / KEYCLOAK_MAX_EVENTS). When False, treat the result as a lower bound and do NOT publish a definitive verdict; narrow hours and retry. spray: flagged IPs (see external_ips for the row shape), each with breached = list of evidence tuples; confidence: high rows first. external_ips: every external IP with at least min_report_users distinct users, flagged or not, sorted flagged-first, then high-confidence first, then by ascending success rate — so near-misses (e.g. 8 users at 14%) are visible without a second rule. Row: ip, known_egress, flagged, confidence ("high"/"low"), signals (list of the reasons for "low"), unique_users, users_with_success, user_success_rate, failure_concentration, top_failed_users (up to 5 {username, failures}), not_found_domains (domain -> count for user_not_found usernames), attempts, successes, failures, success_rate, errors (error-code counter; user_not_found mixed with invalid_user_credentials on many DIFFERENT names indicates a scraped username list — on the same few names it is a human retyping), first_seen, last_seen, unresolved_user_ids, breached. breached_total: number of evidence tuples across all flagged IPs. breached_low_confidence: how many of those sit on confidence: low rows (candidates to verify, not breaches). internal_events_excluded: events dropped because the IP is internal. resolves_used / resolve_capped: how many GET /users/{id} lookups were spent resolving success userIds, and whether max_resolves (or the shared deadline) stopped further lookups. A row's unresolved_user_ids counts successes keyed by bare userId; if that is non-zero on a flagged row, unique_users may be slightly over-counted and breached[].username is the id. known_egress_configured: whether KEYCLOAK_KNOWN_EGRESS is set. IPs in those ranges are LABELED known_egress: true, never excluded — a shared VDI/VPN/proxy egress with many real users is expected to show a high success rate and usually is not flagged anyway.

Users are keyed by lowercased username on both sides: LOGIN_ERROR carries details.username; LOGIN usually carries only userId. userIds are mapped from the fetched events first (any event carrying both fields), then via GET /users/{id} — only for IPs below the success-rate ceiling (the only ones that can be flagged), at most max_resolves times, and never past the shared KEYCLOAK_DEADLINE.

Time-bounded: this call stops after KEYCLOAK_DEADLINE seconds (default 45) and returns what it has; the counts are then a lower bound. The returned dict then has complete: false (no warning text); use a smaller hours. Call again with a narrower window instead of retrying the same call. A wide window on a busy day is what triggers it.

Args: hours: Look-back window (default 24 — sized for a once-a-day patrol). min_users: Distinct users an IP must touch to count as a spray (default 10). max_success_rate: Success-rate ceiling for a spray (default 0.2). min_report_users: Distinct users an IP needs to appear in external_ips at all (default 3; clamped to min_users). max_resolves: Cap on GET /users/{id} lookups per call (default 200). max_user_success_rate: user_success_rate at or above this marks the row confidence: low (default 0.3). max_failure_concentration: failure_concentration at or above this marks the row confidence: low (default 0.5).

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
hoursNo
min_usersNo
max_resolvesNo
max_success_rateNo
min_report_usersNo
max_user_success_rateNo
max_failure_concentrationNo

Schema Changelog

Changes observed during successful MCP inspections.

  1. Addedv0.19.0

TDQS

A4.5/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the full burden and does so exhaustively: it discloses the deadline/time-bounded behavior, the complete:false lower-bound semantics, confidence labeling rules, known-egress labeling (never excluded), resolve caps and shared-deadline stopping, and the invariant that breaches come only from actual LOGIN events.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness3/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The purpose and interpretation guidance are front-loaded, but the block is very long and dense, with the return-shape enumeration and several repeated cautions (publish/lower-bound) that could be tightened. Detail is largely warranted given no output schema, but it is closer to a wall of text than a crisp definition.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

There is no output schema, so the description must explain returns, and it does so thoroughly (window, complete, spray, external_ips, breached_total, resolves_used, etc.), along with the KEYCLOAK env dependencies and interpretation rules. An agent has everything needed to call and read this tool correctly.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must compensate and largely does: all seven parameters are documented in the Args block with defaults and meaning (including the min_report_users clamping to min_users). One inconsistency weakens it: max_user_success_rate is documented as default 0.3 while the schema declares 0.2.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource ('Detect password-spray sources and name the accounts they breached') with a clear scope via the detection rule (unique_users >= min_users AND success_rate < max_success_rate). This distinguishes it from generic siblings like get_login_failures_by_ip and get_brute_force_status, which lack breach-naming and confidence semantics.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Gives strong operational context: read top_failed_users and not_found_domains before writing up, treat low-confidence successes as 'verify with the owner', and re-call with a narrower window rather than retrying. It never explicitly names which sibling to use instead, so the when-to-use-vs-alternative routing is implied rather than stated.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.