Описание
Security-guarded MCP server for Bugzilla, written in Rust
README
bugwarden
bugwarden is a Model Context Protocol (MCP) server, written in Rust, with operator-controlled security guards. It exposes a Bugzilla instance to LLM clients — querying bugs, searching, reading comments and history, and (where permitted) updating bugs — while a policy file that the model can neither see nor change decides, per bug, what the model is allowed to do.
The Bugzilla REST API already enforces user permissions via the API key. What it cannot do is enforce a narrower set of permissions for an AI agent acting on that user's behalf. bugwarden sits in between: the operator writes a small TOML policy ("embargoed security bugs are invisible", "on the Security product the agent may only read summaries and leave comments", "nothing younger than a week exists"), and every tool call is checked against it before Bugzilla is touched or data is returned.
Features
- Complete Bugzilla tool surface: bug details, history, comments, attachment metadata and content, quicksearch, comment/status/field/ assignee/CC/dependency updates, duplicate marking, bug filing, attachment upload, server info, quicksearch syntax docs, and a bug-summarization prompt tool.
- Guard policy engine: per-bug
allow/deny/restrictdecisions matched on product, component, group, keyword, status, severity, priority, whiteboard, summary, group-restrictedness, bug age, and authorship (whether the requesting account filed the bug) — with a fine-grained 13-capability vocabulary forrestrict. - No existence oracle: a policy-denied bug is indistinguishable from a nonexistent one.
- Silent search filtering: denied bugs simply never appear in search results; summary-only bugs appear redacted.
- Minimum-age quarantine:
min_bug_age_daysmakes recently filed bugs (the ones most likely to contain not-yet-triaged sensitive data) invisible. - Read-only mode and tool disabling remove write tools from the MCP tool listing entirely — clients never see them, rather than seeing them error.
- Two transports: streamable HTTP (per-request API key header, or a
server-held key via
--api-key-filefor fleet deployments) and stdio (subprocess launch by a desktop MCP client). - Single static binary, async throughout (tokio + rmcp).
Security model
The guard concept
The guard policy is loaded once, at startup, from a TOML file passed via
--policy (or BUGWARDEN_POLICY). It lives on the operator's filesystem. The
MCP client — i.e. the model — has no tool to read it, list its rules, or
modify it. mcp_server_info intentionally exposes only coarse facts: the rule
count, the default action, min_bug_age_days, whether the server is
read-only, and which tool names are disabled. Rule names and match criteria
are never revealed.
Every tool that takes a bug id first fetches the bug's classification metadata
(product, groups, keywords, creation time, …) and evaluates the policy
before any side effect happens or any data is returned. The only exception
is bug_url, which computes a URL string locally and contacts nothing.
Invariants
- Uniform denial. A denied bug and a nonexistent bug produce the exact
same response:
Bug {id} is not accessible through this server. No wording or detail difference can be used as an existence oracle for embargoed bugs. - Silent search filtering. Search results are post-filtered through the policy; the client is never told how many results were dropped or that filtering happened at all. (Server-side debug logs do record it for the operator.)
- Fail closed. If the classification fetch fails, if a bug is absent from
the response, or if a rule consulted for the operation being decided cannot
be decided because the bug object did not carry a field that rule asks
about — or, for the identity criterion
created_by_me, because the bug–caller relationship could not be established — the bug is treated as denied — never as allowed. (A rule scoped away from the operation viaoperationsis not consulted at all — scoping changes which rules run, never how a consulted rule resolves.) - Private-comment gate. Private comments are returned only when the policy
sets
allow_private_comments = trueand the individual call opts in withinclude_private = true. Either alone is not enough. - Custom fields cannot smuggle writes.
update_bug_fields.custom_fieldsaccepts only keys starting withcf_; anything else (e.g.groups,cc,assigned_to) is rejected before Bugzilla is contacted. - The API key never leaks. The Bugzilla API key is never written to logs, error messages, or tool results; HTTP errors are sanitized so that a key passed as a URL query parameter cannot appear in error text.
- CLI can only tighten.
--read-onlyORs into the policy's read-only flag; there is no CLI switch that loosens the policy.
Deliberate omissions and strict defaults
- No header-echo tool. Incoming request headers — including the API-key header — are never exposed to the model.
- Private comments default to off. The default policy has
allow_private_comments = false, so a policy file is required to enable them. update_bug_fieldscustom fields are restricted tocf_*keys as described above.
Installation
openSUSE (zypper)
bugwarden is packaged in openSUSE Tumbleweed:
sudo zypper install bugwarden
For other openSUSE distributions (Leap 16.x, Slowroll), packages are built in the devel:tools project on the openSUSE Build Service.
The package installs worked-example configuration files —
/etc/bugwarden/policy.toml (guard policy) and /etc/bugwarden/audit.toml
(audit stream) — marked %config(noreplace), so local edits survive package
upgrades. Neither is loaded implicitly: the server reads a policy only when
one is named via --policy / BUGWARDEN_POLICY, and an audit configuration
only via --audit-config / BUGWARDEN_AUDIT_CONFIG, so installing the
package does not by itself activate anything.
crates.io (cargo)
cargo install bugwarden
This installs the bugwarden binary into ~/.cargo/bin. Unlike the openSUSE
package it ships no configuration files — copy
examples/policy.toml somewhere and name it via
--policy.
From source
git clone https://github.com/plusky/bugwarden
cd bugwarden
cargo build --release
# binary at target/release/bugwarden
The repository pins its Rust toolchain via rust-toolchain.toml; cargo
picks it up automatically (rustup-managed installs). Any recent stable Rust
works if you build without the pin.
Usage
Note: some Bugzilla deployments protect their interactive host with an anti-bot challenge that rejects API clients regardless of credentials. If tools fail with "response body is not valid JSON", check whether the instance offers a dedicated API host (for example
apibugzilla.suse.cominstead ofbugzilla.suse.com) and point--bugzilla-serverat that.
HTTP transport (default)
The server listens on http://<host>:<port>/mcp. Each client request carries
the Bugzilla API key in an HTTP header (default header name: ApiKey), so one
server can serve multiple users with their own keys:
bugwarden \
--bugzilla-server https://bugzilla.opensuse.org \
--policy /etc/bugwarden/policy.toml \
--host 127.0.0.1 --port 8000
MCP client configuration (exact format varies by client):
{
"mcpServers": {
"bugzilla": {
"url": "http://127.0.0.1:8000/mcp",
"headers": {
"ApiKey": "YOUR_BUGZILLA_API_KEY"
}
}
}
}
The header name is configurable with --api-key-header. For Bugzilla
instances that reject the api_key query parameter and require
Authorization: Bearer (e.g. Red Hat Bugzilla), add --use-auth-header —
this affects only server-to-Bugzilla authentication, not the client-facing
header.
Server-held key mode (fleet deployments)
With --api-key-file the Bugzilla API key belongs to the server: every
request is served with the key read from that file, clients present no
credential at all, and the per-request header is not consulted — a request
that does carry one is served with the server's key, and the header value is
never read. There is no fallback between the two modes in either
direction (handing clients the real key would let them bypass the guard by
talking to Bugzilla directly). This fits deployments where the key is
provisioned as a container secret or a systemd credential
(LoadCredential=bugzilla-key:/etc/bugwarden/bugzilla-key plus
--api-key-file ${CREDENTIALS_DIRECTORY}/bugzilla-key):
bugwarden \
--bugzilla-server https://bugzilla.opensuse.org \
--policy /etc/bugwarden/policy.toml \
--api-key-file /run/secrets/bugzilla-key \
--host 127.0.0.1 --port 8000
The file's content is trimmed, so a trailing newline is fine; an empty or unreadable file is a startup error naming the path (never its contents). The file is read exactly once, at startup — rotating the key requires a restart. Keep it mode 0600: bugwarden warns when group or others can access it.
One policy consequence to know: in this mode every client authenticates to
Bugzilla — and resolves identity — as the service account that owns the key,
so a policy rule matching on created_by_me describes that one account's
bug reports for all clients, not each caller's own. bugwarden warns at
startup when server-held mode meets such a policy.
stdio transport
For MCP clients that launch the server as a subprocess and speak over
stdin/stdout. There are no per-request HTTP headers here, so the API key must
be provided up front via --api-key / BUGZILLA_API_KEY or --api-key-file
(starting without one is an error):
BUGZILLA_API_KEY=your_api_key \
bugwarden \
--bugzilla-server https://bugzilla.opensuse.org \
--transport stdio \
--policy /etc/bugwarden/policy.toml
MCP client configuration:
{
"mcpServers": {
"bugzilla": {
"command": "/usr/local/bin/bugwarden",
"args": [
"--bugzilla-server", "https://bugzilla.opensuse.org",
"--transport", "stdio",
"--policy", "/etc/bugwarden/policy.toml"
],
"env": {
"BUGZILLA_API_KEY": "YOUR_BUGZILLA_API_KEY"
}
}
}
}
CLI reference
Command-line arguments take precedence over environment variables.
| Flag | Environment variable | Default | Description |
|---|---|---|---|
--bugzilla-server <URL> |
BUGZILLA_SERVER |
required | Base URL of the Bugzilla server (e.g. https://bugzilla.opensuse.org) |
--transport <http|stdio> |
MCP_TRANSPORT |
http |
MCP transport. stdio is for subprocess launches by an MCP client; http exposes a network endpoint at /mcp |
--host <ADDRESS> |
MCP_HOST |
127.0.0.1 |
Listen address (http transport only) |
--port <PORT> |
MCP_PORT |
8000 |
Listen port (http transport only) |
--api-key-header <NAME> |
MCP_API_KEY_HEADER |
ApiKey |
HTTP header name in which clients send the Bugzilla API key (http transport only) |
--api-key <KEY> |
BUGZILLA_API_KEY |
— | Bugzilla API key. Required for --transport stdio unless --api-key-file provides it; with http it is ignored with a warning (clients send the key per request — use --api-key-file for a server-held key) |
--api-key-file <PATH> |
BUGZILLA_API_KEY_FILE |
— | Path to a file holding the Bugzilla API key (container secret, systemd LoadCredential path). Mutually exclusive with --api-key; an empty value counts as unset. Over http this selects server-held key mode: every request is served with this key and the per-request header is not consulted |
--use-auth-header |
— | false |
Authenticate to Bugzilla with Authorization: Bearer <key> instead of the api_key query parameter |
--read-only |
MCP_READ_ONLY |
false |
Disable all write tools. Tighten-only: ORed with the policy's global.read_only; cannot re-enable writes a policy forbids |
--policy <PATH> |
BUGWARDEN_POLICY |
— | Path to the guard policy TOML. Without it, an allow-all policy applies (with private comments off) |
--audit-config <PATH> |
BUGWARDEN_AUDIT_CONFIG |
— | Path to the audit stream configuration TOML (worked example in examples/audit.toml). Without it, no audit stream is written. Records carry W3C trace ids when the client sends a traceparent in the request's _meta, enabling correlation with client-side traces |
Policy file reference
The policy is strict TOML: unknown keys anywhere are a startup error, as
is a restrict rule without capabilities, an allow/deny rule with
capabilities, or default_action = "restrict". On Unix, bugwarden logs a
warning at startup if the policy file is group- or other-writable.
A complete, commented example ships in examples/policy.toml.
Top level
| Key | Type | Default | Description |
|---|---|---|---|
default_action |
"allow" | "deny" |
"allow" |
Applied when no rule matches a bug. Must not be "restrict" (a catch-all restrict rule expresses that instead) |
[global]
| Key | Type | Default | Description |
|---|---|---|---|
min_bug_age_days |
integer | 0 (disabled) |
Bugs created less than N days ago are invisible — treated exactly like nonexistent bugs, evaluated before any rule. A bug whose creation_time is missing or unparsable is denied (fail closed) |
allow_private_comments |
boolean | false |
Master switch for all private content: comments, attachment metadata, and attachment downloads. Even when true, each call must also pass include_private = true. On an attachment download a missing privacy flag counts as private |
read_only |
boolean | false |
Strip write capabilities from every grant and remove write tools from the tool listing. The --read-only flag ORs into this |
disabled_tools |
array of strings | [] |
Tool names to remove from the tool listing entirely |
max_attachment_bytes |
integer | 2097152 (2 MiB) |
Largest attachment download_attachment may return (decoded size). 0 removes the cap. Attachment content is embedded base64 in the tool result and lands in the model's context — raise deliberately |
[[rule]]
Rules are evaluated top to bottom; the first rule whose matcher matches the
bug wins and later rules are ignored. If no rule matches, default_action
applies. Put your most specific (usually most restrictive) rules first.
| Key | Type | Default | Description |
|---|---|---|---|
name |
string | required | Rule identifier (server-side logs only; never shown to clients) |
description |
string | "" |
Free-form operator documentation |
match |
table | {} (matches every bug) |
Match criteria, see below |
action |
"allow" | "deny" | "restrict" |
required | allow grants all capabilities, deny grants none, restrict grants exactly capabilities |
capabilities |
array of capability strings | [] |
Only for action = "restrict", where at least one is required. Must be empty/absent for allow and deny |
operations |
array of "create" | "access" |
absent (rule applies to every operation) | Scopes the rule to the named operations: create is the create gate judging a prospective create_bug request, access is every classification of an existing bug (retrieval, search filtering, comments, history, attachments, updates). The scope is checked before the matcher, so a scoped rule is completely invisible to the operations it does not cover — a create-scoped rule can never hide an existing bug. An explicitly empty list is a startup error, as is a restrict rule whose scope and capabilities disagree about create: a rule scoped to only create must grant exactly the create capability (the create gate consults nothing else), and a rule scoped away from create must not grant it (nothing else consults it). Older bugwarden versions reject a policy using this key at startup (strict parsing — the file fails closed rather than being misread) |
Note that a restrict rule's capabilities list is the complete grant
for every operation the rule covers, not an addition to what other rules or
default_action would have granted — that is why a rule granting only
create should carry operations = ["create"], so it decides filing without
becoming the first-match rule for reads of the bugs it matches.
match criteria
All criteria present in a matcher must hold (AND). Within a single list,
any element may match (OR). An empty matcher matches every bug — a rule
with no match is a catch-all. To express "criterion A or criterion B",
write two consecutive rules.
| Key | Type | Matched against |
|---|---|---|
products |
array of globs | the bug's product |
components |
array of globs | any of the bug's components |
groups |
array of globs | any of the bug's group names |
keywords |
array of globs | any of the bug's keywords |
statuses |
array of globs | the bug's status |
severities |
array of globs | the bug's severity |
priorities |
array of globs | the bug's priority |
whiteboard_contains |
array of strings | case-insensitive substring search in the whiteboard |
summary_contains |
array of strings | case-insensitive substring search in the bug's one-line summary |
group_restricted |
boolean | true matches bugs readable only through at least one Bugzilla group, false matches world-readable bugs |
younger_than_days |
integer | matches bugs created within the last N days |
created_by_me |
boolean | whether the API key's account authored the bug: the caller's login is resolved per request via Bugzilla's whoami endpoint (at most one lookup per tool call, and none at all under a policy without an access-covering created_by_me rule — a rule scoped to operations = ["create"] alone never triggers a lookup) and compared case-insensitively to the bug's creator. true matches the caller's own reports, false everyone else's. An unresolvable identity (whoami failure) makes the criterion unknown, which denies (see Unreadable metadata). In the create gate the prospective bug always counts as created by the caller — no lookup happens there. Older bugwarden versions reject a policy using this key at startup (strict parsing fails closed) |
Unreadable metadata
Every criterion needs a field the bug object may not carry — absent, null,
of an unexpected type, or a list with an element the parser cannot read. Such
a field is unknown, and a rule that consults one is undecidable: it neither
holds nor fails. One criterion needs more than the bug object:
created_by_me also needs the caller's identity, and if either half is
missing — an unreadable creator, or a whoami lookup that failed — it is
just as undecidable and resolves the same way. A policy consulting identity
therefore denies everything its identity rules are consulted for while
whoami is failing; that is deliberate (treating unknown identity as "does
not match" would let a created_by_me deny rule be defeated by breaking
whoami). The criterion cannot widen exposure beyond the credential:
Bugzilla enforces its own permissions on every fetch, so an authorship rule
only surfaces bugs the API key could already read.
bugwarden resolves an undecidable rule by denying the bug, whatever the
rule's action. A deny rule denies because the bug may well be what it was
written to catch. An allow or restrict rule denies too — it may not grant
access on data nobody could check, and it may not simply be skipped either,
because skipping would hand the bug to a later rule or to default_action. So
unreadable metadata never buys a bug more access than readable metadata would.
Two things this deliberately does not do. A criterion that already failed
definitively wins over an unknown one, so a rule ruled out by another criterion
stays ruled out. And only the fields a rule actually consults matter — a
missing whiteboard is irrelevant to a rule that never mentions the whiteboard.
Likewise, only the rules actually consulted matter: a rule scoped away from
the operation being decided (operations) is skipped before its matcher runs,
so its criteria cannot make anything undecidable for that operation.
A field that is present but empty ("", []) is knowledge, not ignorance, and
is matched normally.
Glob syntax
Globs match the whole value, case-insensitively. * matches any (possibly
empty) substring; every other character is literal. There are no other
metacharacters. Examples: embargo*, *security*, SUSE *.
Capabilities
Thirteen capabilities exist. read implies summary; nothing else is
implied.
Upgrading from a version without
create/attach: the capability set grew from eleven to thirteen, andallow(rules anddefault_action = "allow"alike) always grants the full set. A policy written before these capabilities existed therefore starts permitting bug filing and attachment upload the moment the server is upgraded, with no change to the policy file. To keep the old behaviour, either adddisabled_tools = ["create_bug", "add_attachment"]under[global], or replaceallowgrants withrestrictrules listing exactly the capabilities you mean. Read-only deployments are unaffected (both new capabilities are writes).
| Capability | Kind | Grants |
|---|---|---|
read |
read | full bug details (implies summary) |
summary |
read | redacted summary-only view (id, summary, status, resolution, product, component, severity, priority, creation/last-change time) |
comments |
read | reading comments (also needed by summarize_bug) |
history |
read | reading the bug's change history |
attachments |
read | listing attachment metadata and downloading attachment content |
comment |
write | adding a comment |
status |
write | changing status/resolution, marking duplicates |
fields |
write | changing priority, severity, resolution, summary, URL, whiteboard, version, target milestone, keywords, see-also links, cf_* custom fields |
assign |
write | changing the assignee |
cc |
write | modifying the CC list |
deps |
write | changing blocks/depends_on |
create |
write | filing a new bug — judged against the bug as requested, so a rule that hides a product by name also refuses filing into it. The request's groups claim is never trusted (Bugzilla adds mandatory groups server-side), so a rule consulting groups or group_restricted refuses every create request that reaches it — to accept new bugs under such a policy, grant create in a rule scoped with operations = ["create"] placed before the group-consulting rules; being create-scoped, the grant leaves reads of existing bugs untouched, and without such a grant the policy refuses all bug filing |
attach |
write | uploading an attachment to a bug |
When the server is read-only (policy or CLI), the eight write capabilities
are stripped from every grant, including from allow rules and the default
action.
Tool reference
| Tool | What it does | Required capability |
|---|---|---|
bug_info |
Details for a set of bug ids. Per id: full details with read, redacted summary with summary, otherwise a uniform "not accessible" entry |
read / summary |
bug_history |
Change history of a bug, optionally only entries newer than a timestamp | history |
bug_comments |
Comments on a bug; private comments only per the private-comment gate | comments |
bugs_quicksearch |
Bugzilla quicksearch — the status filter (default ALL) is prefixed to the query, and under any non-empty status a number in the query is content-matched, so it also matches bugs that merely mention it; with an empty status the query goes to Bugzilla bare, where a query of nothing but numbers is an exact id lookup (use bug_info for an exact set of known ids; an all-ids query gets an advisory note saying so). Results are silently policy-filtered (denied dropped, summary-only redacted) |
per result: read / summary |
summarize_bug |
Returns a summarization prompt built from the bug's public comments | comments |
list_attachments |
Attachment metadata (never attachment content) | attachments |
download_attachment |
Content of one attachment (raster images as image content, everything else as a base64 blob resource), capped by max_attachment_bytes; private attachments need the private-content double opt-in and, on download, a missing privacy flag counts as private |
attachments on the owning bug |
add_comment |
Add a comment to a bug | comment |
update_bug_status |
Change status/resolution (CLOSED requires a resolution) | status |
assign_bug |
Set the assignee | assign |
update_bug_fields |
Update priority/severity/resolution, summary, URL, whiteboard, version, target milestone, keywords and see-also links (both add/remove, never replace-all), and cf_* custom fields |
fields on the bug and at least summary on every see-also target on this instance |
update_bug_dependencies |
Add/remove blocks and depends_on entries | deps |
add_cc_to_bug |
Add an email to the CC list | cc |
mark_as_duplicate |
Close a bug as DUPLICATE of another | status on the bug and at least summary on the duplicate target |
create_bug |
File a new bug; the request is policy-checked as described before anything is created. A policy refusal and a Bugzilla-side failure return the same refusal text at the same cost, so a failed create never says which of the two refused, or why | create on the bug as requested |
add_attachment |
Upload a base64-encoded attachment to a bug, capped by max_attachment_bytes (decoded size) |
attach on the target bug |
bug_url |
Compute {server}/show_bug.cgi?id={id} locally |
none (contacts nothing) |
bugzilla_server_info |
Bugzilla version, extensions, timezone, time, parameters | none |
quicksearch_syntax |
Bugzilla's quicksearch syntax documentation (HTML) | none |
mcp_server_info |
bugwarden version, Bugzilla URL, transport, coarse policy summary | none |
License
Apache License 2.0. See LICENSE for details.
Установка Bugwarden
У этого сервера нет опубликованного пакета — он собирается из исходников. Открой репозиторий и следуй инструкции в README.
▸ github.com/plusky/bugwardenFAQ
Bugwarden MCP бесплатный?
Да, Bugwarden MCP бесплатный — установка в пару кликов через Unyly без оплаты.
Нужен ли API-ключ для Bugwarden?
Нет, Bugwarden работает без API-ключей и переменных окружения.
Bugwarden — hosted или self-hosted?
Self-hosted: сервер запускается локально на твоей машине командой из раздела установки.
Как установить Bugwarden в Claude Desktop, Claude Code или Cursor?
Открой Bugwarden на unyly.org, выбери вкладку своего клиента (Claude Desktop, Claude Code, Cursor) и нажми Install — конфиг сгенерируется автоматически, без правки JSON.
Похожие MCP
GitHub
PRs, issues, code search, CI status
автор: GitHubFilesystem
Secure file operations with configurable access controls.
Memory
Knowledge graph-based persistent memory system.
Template MCP Server
A CLI tool to create a new Model Context Protocol server project with TypeScript support, dual transport options, and an extensible structure
автор: mcpdotdirectCompare Bugwarden with
Не уверен что выбрать?
Найди свой стек за 60 секунд
Автор?
Embed-бейдж для README
Похожее
Все в категории development
