bugwarden 0.3.0

MCP server for Bugzilla with operator-controlled security guards
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
<p align="center">
  <img src="assets/logo.svg" alt="bugwarden logo" width="180">
</p>

# 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` / `restrict` decisions
  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 for `restrict`.
- **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_days` makes 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-file` for fleet deployments) and stdio
  (subprocess launch by a desktop MCP client).
- Single static binary, async throughout (tokio + [rmcp](https://crates.io/crates/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 via
  `operations` is 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 = true` **and** the individual call opts in
  with `include_private = true`. Either alone is not enough.
- **Custom fields cannot smuggle writes.** `update_bug_fields.custom_fields`
  accepts only keys starting with `cf_`; 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-only` ORs 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_fields` custom fields are restricted** to `cf_*` keys as
  described above.

## Installation

### openSUSE (zypper)

bugwarden is packaged in openSUSE Tumbleweed:

```bash
sudo zypper install bugwarden
```

For other openSUSE distributions (Leap 16.x, Slowroll), packages are built in
the [`devel:tools`](https://build.opensuse.org/package/show/devel:tools/bugwarden)
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)

```bash
cargo install bugwarden
```

This installs the `bugwarden` binary into `~/.cargo/bin`. Unlike the openSUSE
package it ships no configuration files — copy
[`examples/policy.toml`](examples/policy.toml) somewhere and name it via
`--policy`.

### From source

```bash
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.com`
> instead of `bugzilla.suse.com`) and point `--bugzilla-server` at 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:

```bash
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):

```json
{
  "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`):

```bash
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):

```bash
BUGZILLA_API_KEY=your_api_key \
  bugwarden \
  --bugzilla-server https://bugzilla.opensuse.org \
  --transport stdio \
  --policy /etc/bugwarden/policy.toml
```

MCP client configuration:

```json
{
  "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`](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`](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, and `allow` (rules and
> `default_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 add
> `disabled_tools = ["create_bug", "add_attachment"]` under `[global]`, or
> replace `allow` grants with `restrict` rules 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](https://bugzilla.readthedocs.io/en/latest/using/finding.html#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](LICENSE) for details.