redmine-mcp-workflows 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. redmine_mcp_workflows-1.0.0/.gitignore +36 -0
  2. redmine_mcp_workflows-1.0.0/CHANGELOG.md +570 -0
  3. redmine_mcp_workflows-1.0.0/LICENSE +21 -0
  4. redmine_mcp_workflows-1.0.0/PKG-INFO +241 -0
  5. redmine_mcp_workflows-1.0.0/README.md +209 -0
  6. redmine_mcp_workflows-1.0.0/docs/tool-catalog.md +193 -0
  7. redmine_mcp_workflows-1.0.0/docs/workflow-validation.md +135 -0
  8. redmine_mcp_workflows-1.0.0/pyproject.toml +82 -0
  9. redmine_mcp_workflows-1.0.0/src/redmine_mcp/__init__.py +3 -0
  10. redmine_mcp_workflows-1.0.0/src/redmine_mcp/cache/__init__.py +1 -0
  11. redmine_mcp_workflows-1.0.0/src/redmine_mcp/cache/migrations.py +125 -0
  12. redmine_mcp_workflows-1.0.0/src/redmine_mcp/cache/schema_db.py +574 -0
  13. redmine_mcp_workflows-1.0.0/src/redmine_mcp/client.py +194 -0
  14. redmine_mcp_workflows-1.0.0/src/redmine_mcp/config.py +133 -0
  15. redmine_mcp_workflows-1.0.0/src/redmine_mcp/errors.py +277 -0
  16. redmine_mcp_workflows-1.0.0/src/redmine_mcp/schema/__init__.py +1 -0
  17. redmine_mcp_workflows-1.0.0/src/redmine_mcp/schema/custom_fields.py +94 -0
  18. redmine_mcp_workflows-1.0.0/src/redmine_mcp/schema/project.py +127 -0
  19. redmine_mcp_workflows-1.0.0/src/redmine_mcp/schema/tracker.py +151 -0
  20. redmine_mcp_workflows-1.0.0/src/redmine_mcp/schema/workflow.py +75 -0
  21. redmine_mcp_workflows-1.0.0/src/redmine_mcp/secrets.py +45 -0
  22. redmine_mcp_workflows-1.0.0/src/redmine_mcp/server.py +2631 -0
  23. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/__init__.py +1 -0
  24. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/attachments.py +371 -0
  25. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/bulk.py +414 -0
  26. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/comments.py +140 -0
  27. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/custom_fields.py +29 -0
  28. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/discovery.py +83 -0
  29. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/enumerations.py +57 -0
  30. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/files.py +125 -0
  31. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/forums.py +171 -0
  32. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/groups.py +189 -0
  33. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/issue_categories.py +131 -0
  34. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/issue_statuses.py +29 -0
  35. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/issues.py +862 -0
  36. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/memberships.py +140 -0
  37. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/news.py +162 -0
  38. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/passthrough.py +126 -0
  39. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/projects.py +173 -0
  40. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/queries.py +45 -0
  41. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/relations.py +185 -0
  42. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/roles.py +58 -0
  43. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/search.py +128 -0
  44. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/time_entries.py +262 -0
  45. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/users.py +107 -0
  46. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/versions.py +281 -0
  47. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/watchers.py +92 -0
  48. redmine_mcp_workflows-1.0.0/src/redmine_mcp/tools/wiki.py +216 -0
  49. redmine_mcp_workflows-1.0.0/src/redmine_mcp/validation/__init__.py +1 -0
  50. redmine_mcp_workflows-1.0.0/src/redmine_mcp/validation/fields.py +172 -0
  51. redmine_mcp_workflows-1.0.0/src/redmine_mcp/validation/permissions.py +59 -0
  52. redmine_mcp_workflows-1.0.0/src/redmine_mcp/validation/transitions.py +121 -0
  53. redmine_mcp_workflows-1.0.0/tests/__init__.py +0 -0
  54. redmine_mcp_workflows-1.0.0/tests/conftest.py +14 -0
  55. redmine_mcp_workflows-1.0.0/tests/fixtures/cassettes/.gitkeep +0 -0
  56. redmine_mcp_workflows-1.0.0/tests/test_cache.py +394 -0
  57. redmine_mcp_workflows-1.0.0/tests/test_client_auth.py +56 -0
  58. redmine_mcp_workflows-1.0.0/tests/test_config.py +150 -0
  59. redmine_mcp_workflows-1.0.0/tests/test_custom_fields_schema.py +184 -0
  60. redmine_mcp_workflows-1.0.0/tests/test_schema.py +422 -0
  61. redmine_mcp_workflows-1.0.0/tests/test_secrets.py +41 -0
  62. redmine_mcp_workflows-1.0.0/tests/test_server_passthrough.py +155 -0
  63. redmine_mcp_workflows-1.0.0/tests/test_tools/__init__.py +0 -0
  64. redmine_mcp_workflows-1.0.0/tests/test_tools/test_attachments.py +589 -0
  65. redmine_mcp_workflows-1.0.0/tests/test_tools/test_bulk.py +582 -0
  66. redmine_mcp_workflows-1.0.0/tests/test_tools/test_comments.py +241 -0
  67. redmine_mcp_workflows-1.0.0/tests/test_tools/test_forums.py +113 -0
  68. redmine_mcp_workflows-1.0.0/tests/test_tools/test_groups.py +123 -0
  69. redmine_mcp_workflows-1.0.0/tests/test_tools/test_issue_categories.py +156 -0
  70. redmine_mcp_workflows-1.0.0/tests/test_tools/test_issues.py +1279 -0
  71. redmine_mcp_workflows-1.0.0/tests/test_tools/test_memberships.py +117 -0
  72. redmine_mcp_workflows-1.0.0/tests/test_tools/test_news.py +139 -0
  73. redmine_mcp_workflows-1.0.0/tests/test_tools/test_passthrough.py +275 -0
  74. redmine_mcp_workflows-1.0.0/tests/test_tools/test_projects_crud.py +132 -0
  75. redmine_mcp_workflows-1.0.0/tests/test_tools/test_reference_data.py +191 -0
  76. redmine_mcp_workflows-1.0.0/tests/test_tools/test_relations.py +346 -0
  77. redmine_mcp_workflows-1.0.0/tests/test_tools/test_search.py +121 -0
  78. redmine_mcp_workflows-1.0.0/tests/test_tools/test_time_entries.py +281 -0
  79. redmine_mcp_workflows-1.0.0/tests/test_tools/test_users.py +110 -0
  80. redmine_mcp_workflows-1.0.0/tests/test_tools/test_versions.py +463 -0
  81. redmine_mcp_workflows-1.0.0/tests/test_tools/test_watchers.py +156 -0
  82. redmine_mcp_workflows-1.0.0/tests/test_tools/test_wiki.py +417 -0
  83. redmine_mcp_workflows-1.0.0/tests/test_validation.py +481 -0
@@ -0,0 +1,36 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ .venv/
7
+ venv/
8
+ ENV/
9
+ build/
10
+ dist/
11
+ *.egg-info/
12
+ *.egg
13
+
14
+ # Test
15
+ .pytest_cache/
16
+ .coverage
17
+ htmlcov/
18
+ .tox/
19
+
20
+ # Editor
21
+ .vscode/
22
+ .idea/
23
+ *.swp
24
+ *~
25
+
26
+ # Cache (local dev runs)
27
+ .cache/
28
+ *.db
29
+ *.db-journal
30
+
31
+ # vcrpy cassettes are checked in; only local test artifacts are ignored
32
+ .pytest_history
33
+
34
+ # Feature Factory / stargazer context (local-only)
35
+ .stargazer-*
36
+ .stargazer-context.json
@@ -0,0 +1,570 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.7.0] — 2026-06-02
11
+
12
+ ### Added
13
+ - **`redmine_update_journal` .** Edit an existing journal entry's
14
+ notes in place via `PUT /journals/:id.json` (Redmine 5.0+). The API
15
+ user can edit their own notes; users with `edit_issue_notes` permission
16
+ can edit any note. Passing empty notes on a details-only journal
17
+ deletes it. Honors `REDMINE_MCP_READ_ONLY`.
18
+
19
+ ## [0.6.0] — 2026-05-16
20
+
21
+ Held-field gate: non-ticket blockers for Redmine issues.
22
+
23
+ ### Added
24
+ - **`IssueHeld` structured error class.** New error type returned when
25
+ attempting to close a ticket that has a non-empty "Held" custom field.
26
+ Includes `issue_id`, `held_reason`, and optional `held_until` in the
27
+ structured payload. Error format:
28
+ `Cannot close #N: held — "reason" (held until YYYY-MM-DD)`.
29
+ - **`check_held_gate()` validator** in `validation/fields.py`. Inspects
30
+ an issue's `custom_fields` for a non-empty "Held" field. Returns
31
+ `IssueHeld` when the gate is active, `None` otherwise. Whitespace-only
32
+ and date-without-reason values are not considered holds.
33
+ - **Held-gate enforcement in `update_issue()`.** When a status transition
34
+ targets a closed status and the issue has a non-empty "Held" custom
35
+ field, `update_issue` returns `IssueHeld` before sending the PUT. This
36
+ covers `close_issue`, `bulk_close`, and `bulk_update_issues` since all
37
+ delegate to `update_issue`. Non-close transitions (e.g., New → In
38
+ Progress) are unaffected. The check runs before the workflow transition
39
+ check for efficiency (no user fetch needed).
40
+
41
+ ## [0.5.0] — 2026-05-12
42
+
43
+ Dogfood-driven fixes from .
44
+ Feature work: Path A (OAuth2 bearer token),
45
+ (saved-query lookups), and (bulk-create).
46
+
47
+ ### Added
48
+ - **`redmine_bulk_create_issues`.** Bulk-create from
49
+ per-spec dicts with subject idempotency. Pre-checks each subject within
50
+ its project via exact-match (post-filter on Redmine's substring-fuzzy
51
+ `subject` filter) and reports existing matches per `on_duplicate`:
52
+ `"skip"` (default) → `status="skipped"` + `duplicate_of`, `"fail"` →
53
+ `status="failed"` + `error="duplicate_subject"`, `"create_anyway"` →
54
+ bypass the lookup. Default 50ms pacing between POSTs (configurable;
55
+ empirically the floor for not tripping Redmine's per-issue rate cap
56
+ on small VMs). Returns `{results, summary}` with the full per-spec
57
+ outcome. ≤100 specs per call (matches MAX_BATCH_SIZE for the other
58
+ bulk tools). Replaces ~150 lines of direct-HTTP scaffolding in the
59
+ fleet's `ServerOps/scripts/redmine/create-*.py` exemplars with a
60
+ ~30-line call.
61
+ - **`redmine_search_issues` accepts a `query_id` parameter
62
+ .** Forwards to Redmine's `?query_id=N` so callers
63
+ can invoke saved queries directly without dropping to the passthrough.
64
+ Layers with `status` / `project` / `query` / `limit` / `offset` per
65
+ Redmine's standard merge semantics. ``query_id=0`` (default) means
66
+ no saved query — current behavior unchanged.
67
+ - **OAuth2 bearer-token support (Path A).** New
68
+ `REDMINE_OAUTH_TOKEN` env var (and matching `REDMINE_OAUTH_TOKEN=...`
69
+ line in the secrets file) lets the wrapper authenticate via
70
+ Doorkeeper-issued access tokens (Redmine 6.1+) sent as
71
+ `Authorization: Bearer <token>`. When both `REDMINE_OAUTH_TOKEN` and
72
+ `REDMINE_API_KEY` are configured, the OAuth bearer wins — OAuth is the
73
+ explicit-opt-in path and shouldn't be shadowed by a stale API key in
74
+ `secrets.md`. New `Config.require_auth_headers()` returns the right
75
+ header dict; `Config.require_api_key()` retained for back-compat.
76
+ Path B (auth-code flow + local-callback PKCE) and Path C (device
77
+ grant — needs Redmine image rebuild) deferred to future tickets.
78
+ - **Silent status no-op detection on `redmine_update_issue` and
79
+ `redmine_close_issue`.** Previously, when Redmine accepted a PUT (2xx)
80
+ but didn't apply the requested `status_id` change — most commonly because
81
+ the `block_descendants_issues_closing` setting blocks parents with open
82
+ subtasks, or a custom workflow rule the cache hasn't yet observed — the
83
+ caller got a success-looking payload with the old status. Now the
84
+ post-PUT re-fetch includes `children` and the response is a structured
85
+ `status_change_silently_ignored` error with a hint that names the
86
+ blocking subtasks (if the target was a closed status and children exist)
87
+ or lists the likely causes generically. Returns `requested_status_id`,
88
+ `requested_status`, `actual_status_id`, `actual_status`, plus the issue
89
+ payload for inspection. Workflow observations are NOT recorded for
90
+ silent no-ops — only confirmed-moved transitions count.
91
+
92
+ ### Fixed
93
+ - **`redmine_upload_attachment` verifies the attachment actually attached
94
+ .** Redmine's `PUT /issues/{id}.json` with `uploads:[...]`
95
+ silently drops the attachment under per-issue rate pressure (observed:
96
+ ~1 PUT/sec cap), returning HTTP 200 regardless. The wrapper now does a
97
+ post-PUT `GET /issues/{id}.json?include=attachments` and checks that the
98
+ uploaded filename appears in `attachments[]`. If not, it retries the
99
+ PUT with the same token after 2s, then 5s, then surfaces a structured
100
+ `attachment_not_attached` error preserving the upload token so the
101
+ caller can recover manually. Backoffs tunable via
102
+ `ATTACHMENT_VERIFY_BACKOFFS` (tests monkeypatch to `(0, 0)`).
103
+ - **`redmine_list_projects` substring filter now searches across all
104
+ pages.** Previously the `query` filter only saw the first page returned
105
+ by `/projects.json` with the given `limit`/`offset` — matches that lived
106
+ later in the list silently dropped (caught dogfooding: `query="mcp"`
107
+ with default `limit=25` missed `kronos-mcp` even though it exists).
108
+ When `query` is set, list_projects now walks every page (capped at
109
+ 1000 projects) before filtering, then re-slices by `limit`/`offset`.
110
+ `total_count` reflects the *filtered* total when filtering, so callers
111
+ can paginate over matches directly. Unfiltered behavior unchanged.
112
+
113
+ - **`redmine_create_issue` and `redmine_update_issue` expose more fields
114
+ .** New params on both:
115
+ - `due_date: str = ""` (ISO-8601 date)
116
+ - `start_date: str = ""` (ISO-8601 date)
117
+ - `done_ratio: int = -1` (sentinel for unchanged; `0` is explicit)
118
+ - `custom_fields: list | str = ""` (accepts native list or JSON string)
119
+ Plus `fixed_version_id` is now first-class on the `redmine_update_issue`
120
+ MCP wrapper (it was already supported internally). Previously these
121
+ fields were only reachable via `redmine_request`, which itself was
122
+ unusable . New `_normalize_custom_fields` helper handles the
123
+ dual list/string shape with a structured error for malformed input.
124
+ - **`redmine_request` accepts dict bodies and params.**
125
+ Some MCP transports auto-parse JSON-shaped string args into objects
126
+ before the tool sees them, which caused pydantic to reject the input
127
+ against the previous `body: str` schema with
128
+ `Input should be a valid string [type=string_type, input_value={...}]`.
129
+ Made the escape hatch genuinely an escape hatch: `body` and `params`
130
+ now accept either a JSON-encoded string (existing) or a dict (new),
131
+ with the wrapper normalizing to the internal dict form. Empty string
132
+ and empty dict both mean "no body / no params" (unchanged semantics).
133
+
134
+ ### Notes
135
+ - 359 tests pass (was 309) — 50 new across the regression fixes + features.
136
+
137
+ ## [0.4.0] — 2026-05-11
138
+
139
+ ### Added
140
+ - **Difficulty custom-field support.** `redmine_create_issue` and
141
+ `redmine_update_issue` now accept a `difficulty` parameter for the
142
+ global `Difficulty` custom field. Values: `Unclassified` / `Easy` /
143
+ `Normal` / `Hard`. Engagement-mode signal (how much human oversight a
144
+ ticket needs), distinct from `Priority`.
145
+ - **Default-fill on create** — `redmine_create_issue` without a
146
+ `difficulty` arg (and without a `Difficulty` entry in `custom_fields`)
147
+ default-fills `Unclassified`, so auto-callers don't trip the
148
+ required-field validation. `redmine_update_issue` does **not**
149
+ default-fill (would silently overwrite user-set values).
150
+ - **Conflict resolution** — when both `difficulty=...` and a matching
151
+ `custom_fields` entry are passed, `difficulty` wins.
152
+ - **`redmine_describe_tracker`** output now includes a `custom_fields`
153
+ array listing the issue custom fields applicable to that tracker
154
+ (id, name, format_kind, is_required, default_value, possible_values,
155
+ applicable_tracker_ids, for_all_projects). Lazy-loads
156
+ `/custom_fields.json` on cache miss.
157
+ - New module `redmine_mcp.schema.custom_fields` with
158
+ `refresh_custom_fields()` + `get_custom_field_by_name()`.
159
+ - New schema-cache table `custom_fields` (migration v3) plus
160
+ `put_custom_field` / `get_custom_field` / `list_custom_fields` /
161
+ `get_custom_field_by_name` accessors on `SchemaCache`.
162
+
163
+ ### Notes
164
+ - `/custom_fields.json` is admin-only on Redmine. If the configured API
165
+ key lacks admin scope, custom-field discovery silently returns empty —
166
+ `redmine_create_issue` / `redmine_update_issue` still work, but the
167
+ `difficulty=` convenience parameter cannot resolve the field id and
168
+ becomes a no-op.
169
+ - Redmine 6.x's REST list response omits the `is_for_all` key, so
170
+ `for_all_projects` is derived from absence of a `projects` array on
171
+ the field record.
172
+
173
+ Refs: serverops #2582, claudecode #2583.
174
+
175
+ ### Security
176
+ - Bump `mcp` SDK pin to `>=1.23.0,<1.24.0` (running 1.23.3) to clear three high-severity advisories on the prior `<1.9.0` pin: MCP Python SDK missing DNS rebinding protection (fixed 1.23.0), FastMCP validation-error DoS (1.9.4), and Streamable HTTP Transport unhandled-exception DoS (1.10.0). Held on `<1.24.0` (vs. the fleet canary digitalocean-dns-mcp on `<2.0.0`) because this server is in active dogfood. All 279 tests pass on 1.23.3.
177
+
178
+ ### Fixed
179
+ - `redmine_create_issue(project=...)` now accepts the project's display
180
+ name (e.g. `"Infra"`), not just the lowercase identifier slug
181
+ (`"infra"`). The natural round-trip pattern of reading
182
+ `redmine_get_issue(...).project.name` and feeding it back into
183
+ `redmine_create_issue` was failing with a generic `redmine_api_404`
184
+ because the slug lookup raised, the listing fallback never ran, and
185
+ there was no clue that "name vs slug" was the real problem
186
+ .
187
+ - `_resolve_project_id` now falls through slug → cache-by-name →
188
+ refreshed `/projects.json` listing-by-name before giving up.
189
+ Successful name resolutions warm the cache so subsequent calls are
190
+ cheap.
191
+ - `schema/project.describe_project` catches the upstream `RedmineAPIError(404)`
192
+ and returns a structured `project_not_found` dict instead of
193
+ bubbling the exception, so the resolver can fall through cleanly.
194
+ - `SchemaCache.get_project_by_name(name)` — case-insensitive search
195
+ across cached projects' `schema_json.name` field. The projects table
196
+ indexes on `identifier` only, so this is a small in-memory scan over
197
+ a small fleet of cached entries (correctness over performance per
198
+ the existing schema_db comment).
199
+ - When neither slug nor name resolves, the response is now the clean
200
+ structured `{"error": "project_not_found", "hint": "No project matches '<value>'.", ...}`
201
+ instead of a generic 404, so LLM callers get actionable specificity.
202
+ - 2 new tests in `tests/test_tools/test_issues.py`:
203
+ - `test_create_issue_resolves_project_by_display_name_via_cache` —
204
+ direct regression for #2568, exercises the cache-by-name path after
205
+ a 404 on the slug.
206
+ - `test_create_issue_resolves_project_by_name_via_list_refresh` —
207
+ exercises the list-refresh fallback when name isn't pre-cached, and
208
+ asserts the cache is warmed for next time.
209
+ - `test_create_issue_returns_project_not_found_when_unresolvable` was
210
+ rewritten to use the realistic 404-from-real-Redmine path (was
211
+ testing a fictional `200 OK` with `{"project": null}` body that real
212
+ Redmine never produces).
213
+ - 279 tests pass (+2 vs prior).
214
+
215
+ ### Added
216
+ - v0.5 first feature: news + forum-board read tools (Redmine
217
+ #2390) — 2 new tools.
218
+ 40 of the planned set live total.
219
+ - `tools/news.py` — `list_news(project=None, limit, offset)`. Empty
220
+ `project` hits `/news.json` (the global feed); a numeric id or slug
221
+ hits `/projects/{id}/news.json` (the project feed). Same tool name
222
+ covers both because callers always know which one they want and
223
+ splitting them adds no value.
224
+ - `tools/forums.py` — `list_messages(board_id, limit, offset)` →
225
+ `/boards/{board_id}/messages.json`. A 404 typically means the
226
+ boards module isn't enabled on the parent project; we surface the
227
+ structured error rather than masking. No `list_boards` companion —
228
+ `/projects/X/boards.json` is inconsistently enabled across Redmine
229
+ versions; `redmine_request` covers it for callers who need it.
230
+ - `server.py` — `redmine_list_news(project="", limit, offset)` and
231
+ `redmine_list_messages(board_id, limit, offset)`. Both read-only.
232
+ - 10 new unit tests in `tests/test_tools/test_news.py` (6) and
233
+ `tests/test_tools/test_forums.py` (4) covering global vs.
234
+ project-scoped paths, default + propagated pagination args, 404
235
+ surfacing, and defensive non-dict-response handling. 277 tests pass
236
+ total; ruff clean.
237
+
238
+ - v0.3 first feature: `redmine_request` generic-passthrough escape hatch
239
+ — 1 new
240
+ tool, opt-in. 38 of the planned set live total.
241
+ - `tools/passthrough.py` — sends arbitrary HTTP requests to any
242
+ Redmine REST endpoint with NO validation, NO workflow check, and
243
+ NO schema cache. Every response carries `validation_skipped: true`
244
+ plus a human-readable `warning` field so callers cannot accidentally
245
+ forget they bypassed the validation layer.
246
+ - **Gated behind `REDMINE_MCP_ENABLE_PASSTHROUGH=true`** — calls
247
+ return a structured `passthrough_disabled` error if the flag isn't
248
+ set. Default-off because the tool is in by-design tension with
249
+ redmine-mcp's "validate first" identity; users opt in when they
250
+ need an endpoint we don't yet wrap.
251
+ - `config.py` — new `enable_passthrough: bool` field plus parsing.
252
+ - `server.py` — new `redmine_request(method, path, body, params)`
253
+ tool. JSON-encoded body / params (parsed client-side). Honors
254
+ `REDMINE_MCP_READ_ONLY` for non-GET methods.
255
+ - 12 new unit tests in `tests/test_tools/test_passthrough.py` covering
256
+ every method (GET / POST / PUT / DELETE), method-case normalization,
257
+ empty-path / missing-leading-slash / unknown-method validation, the
258
+ universal `validation_skipped` flag, and error-envelope round-trip.
259
+ - 3 new tests in `tests/test_config.py` for the new env var (default
260
+ false, truthy / falsey value parsing). 267 tests pass total; ruff clean.
261
+ - Live smoke verified end-to-end:
262
+ read-only GET (with and without query params), POST → PUT → DELETE
263
+ round-trip on a transient passthrough-only ticket (#2440), error
264
+ envelopes for `OPTIONS` / missing-leading-slash / 404, plus gate-off
265
+ default and gate-on enable both confirmed at the server-tool layer.
266
+
267
+ ### Added (continued)
268
+ - v0.2 seventh feature: versions / milestones CRUD (Redmine
269
+ #2382) — 6 new tools:
270
+ `list_versions`, `get_version`, `create_version`, `update_version`,
271
+ `delete_version`, `assign_issue_to_version`. 37 of the planned set
272
+ live total.
273
+ - `tools/versions.py` — full project-versions CRUD against
274
+ `/projects/{p}/versions.json` (list/create) and `/versions/{id}.json`
275
+ (get/update/delete). Status (`open`/`locked`/`closed`) and sharing
276
+ (`none`/`descendants`/`hierarchy`/`tree`/`system`) enums are
277
+ validated client-side so a typo fails fast with a hint instead of
278
+ a generic 422. `due_date` is shape-checked (`YYYY-MM-DD` regex);
279
+ Redmine still does the calendar validation.
280
+ - `tools/issues.py` — `update_issue` extended with
281
+ `fixed_version_id` parameter (passes through to the API; empty
282
+ string clears the assignment). 2 new tests cover the round-trip.
283
+ - `assign_issue_to_version` is a thin convenience wrapper over
284
+ `update_issue` that sets `fixed_version_id` (and accepts `0` as
285
+ the unassign sentinel).
286
+ - `server.py` — registered all 6 tools (5 mutating ones honor
287
+ `REDMINE_MCP_READ_ONLY`).
288
+ - 26 new unit tests (24 in `tests/test_tools/test_versions.py` covering
289
+ every tool's happy + validation + 404 paths, 2 in test_issues.py
290
+ for the new `fixed_version_id` round-trip). 252 tests pass total;
291
+ ruff clean.
292
+ - Live smoke verified end-to-end:
293
+ full create → get → list → assign-issue → unassign → update → delete
294
+ cycle on a transient `v02-smoke-*` version in the `claudecode` project
295
+ (assignment must precede the open→locked status flip — locked
296
+ versions reject new issue assignments).
297
+
298
+ ### Added (continued)
299
+ - v0.2 sixth feature: bulk operations (Redmine
300
+ #2381) — 2 new tools:
301
+ `bulk_update_issues`, `bulk_close`. 31 of the planned set live total.
302
+ - `tools/bulk.py` — thin orchestrators over
303
+ `issues.update_issue` / `issues.close_issue` that validate input
304
+ once (non-empty list, ≤ MAX_BATCH_SIZE=100, at least one updatable
305
+ field on the update tool), iterate sequentially (Redmine has no
306
+ batch endpoint), and aggregate results into
307
+ `{total, succeeded, failed, skipped}`.
308
+ - `stop_on_error=True` halts the batch on first failure and lands
309
+ the unprocessed remainder in `skipped` so callers can retry just
310
+ those; the default (False) is best-effort.
311
+ - `server.py` — registered both tools (mutating, honor
312
+ `REDMINE_MCP_READ_ONLY`).
313
+ - 13 new unit tests in `tests/test_tools/test_bulk.py` covering
314
+ validation, success aggregation, mixed-failure aggregation,
315
+ `stop_on_error` short-circuit, and batch-size cap. 226 tests pass total.
316
+ - Live smoke verified: a single-element
317
+ `bulk_update_issues` posted a journal entry on test issue #2411,
318
+ and `bulk_close([])` returned `validation_failed` as expected.
319
+
320
+ ### Added (continued)
321
+ - v0.2 fifth feature: issue relations (Redmine
322
+ #2380) — 4 new tools:
323
+ `list_relations`, `add_relation`, `remove_relation`,
324
+ `set_parent_issue`. 29 of the planned set live total.
325
+ - `tools/relations.py` — `list_relations` GETs
326
+ `/issues/{id}/relations.json`; `add_relation` POSTs to the same
327
+ path with the new relation; `remove_relation` DELETEs at the
328
+ top-level `/relations/{id}.json` URL (NOT nested under the
329
+ parent issue — easy trap); `set_parent_issue` lives here for
330
+ discoverability even though it's mechanically a PUT to
331
+ `/issues/{id}.json` with `parent_issue_id` (parent/child is a
332
+ field on the issue, not a relation record).
333
+ - Relation-type aliasing: callers often think in colloquial terms
334
+ (`related_to`, `blocked_by`, `duplicate_of`, `duplicated_by`,
335
+ `copy_of`) while Redmine's enum is the source-side form
336
+ (`relates`, `blocked`, `duplicated`, `duplicates`, `copied_from`).
337
+ A small alias map normalizes before posting; unknown types fail
338
+ fast client-side with a structured error listing the canonical
339
+ set.
340
+ - `set_parent_issue` accepts `parent_issue_id=0` as the unparent
341
+ sentinel (sends empty string to Redmine, which is the API's
342
+ "remove parent" form).
343
+ - `server.py` — registered all 4 tools (3 mutating ones honor
344
+ `REDMINE_MCP_READ_ONLY`).
345
+ - 15 new unit tests in `tests/test_tools/test_relations.py` covering
346
+ list / add (with type normalization + delay) / remove / set_parent
347
+ + cross-project 422 propagation. 213 tests pass total.
348
+ - Live smoke verified: added a
349
+ `related_to` relation between test issue #2411 and #2412, listed
350
+ it, then removed it.
351
+
352
+ ### Added (continued)
353
+ - v0.2 fourth feature: wiki page CRUD (Redmine
354
+ #2378) — 4 new tools:
355
+ `get_page`, `create_page`, `update_page`, `delete_page`. 25 of the
356
+ planned set live total.
357
+ - `tools/wiki.py` — Redmine's wiki API uses PUT for both
358
+ create-and-update (returning 201 vs 200 to distinguish). We split
359
+ them at the tool boundary by adding a GET pre-flight to
360
+ `create_page` that refuses to overwrite an existing page (returns
361
+ `wiki_page_already_exists` with the current version so the caller
362
+ can either back off or call `update_page`).
363
+ - `update_page` accepts an optional `version` parameter for
364
+ Redmine's optimistic-concurrency check; a stale version surfaces
365
+ as the underlying `redmine_api_409`. Both `create_page` and
366
+ `update_page` re-fetch after the PUT so the caller gets fresh
367
+ metadata (Redmine usually returns 204 on the write itself).
368
+ - Titles are URL-encoded with `urllib.parse.quote(safe="")` so
369
+ spaces, slashes, and unicode all survive the path. The project
370
+ segment passes through verbatim — Redmine accepts both numeric
371
+ ids and slugs in the wiki URL routing layer.
372
+ - `server.py` — registered all 4 tools (3 mutating ones honor
373
+ `REDMINE_MCP_READ_ONLY`).
374
+ - 21 new unit tests in `tests/test_tools/test_wiki.py` covering each
375
+ tool's happy path + 404 propagation + URL encoding + version-aware
376
+ fetch + already-exists rejection + empty-text validation. 198
377
+ tests pass total.
378
+ - Live smoke verified: full
379
+ create → get → re-create-rejected → update → delete → 404-confirmed
380
+ cycle on a transient page in the `claudecode` project.
381
+
382
+ ### Added (continued)
383
+ - v0.2 third feature: watchers (Redmine
384
+ #2379) — 3 new tools:
385
+ `add_watcher`, `remove_watcher`, `list_watchers`. 21 of the planned
386
+ set live total.
387
+ - `tools/watchers.py` — `add_watcher` POSTs to
388
+ `/issues/{id}/watchers.json` (idempotent on the API side);
389
+ `remove_watcher` DELETEs `/issues/{id}/watchers/{user_id}.json`
390
+ (404 surfaces verbatim so callers can distinguish "not a watcher"
391
+ from "issue not found"); `list_watchers` reuses
392
+ `issues.get_issue(include="watchers")` and lifts the `watchers`
393
+ array to a top-level field (mirrors the `get_journals` pattern).
394
+ - `server.py` — registered the 3 tools (mutating ones honor
395
+ `REDMINE_MCP_READ_ONLY`).
396
+ - 7 new unit tests in `tests/test_tools/test_watchers.py` covering
397
+ each tool's happy + 404-propagation paths. 177 tests pass total;
398
+ ruff clean.
399
+ - Live smoke verified end-to-end:
400
+ add+list+remove cycle on test issue #2411.
401
+
402
+ ### Added (continued)
403
+ - v0.2 second feature: time-entry CRUD (Redmine
404
+ #2377) — 4 new tools:
405
+ `create_time_entry`, `list_time_entries`, `update_time_entry`,
406
+ `delete_time_entry`. 18 of the 14+ tools live total.
407
+ - `tools/time_entries.py` — create accepts ``H:MM`` or decimal hours
408
+ formats (parsed to a single canonical float before round-trip);
409
+ activity names resolve through the cached
410
+ `time_entry_activities` enumeration; ``issue_id`` and
411
+ ``project_id`` are mutually exclusive (issue wins). Update is
412
+ partial — only supplied fields are sent — and re-validates any
413
+ supplied hours. Delete returns `{"deleted": true}` and is
414
+ permanent (no soft-delete in Redmine).
415
+ - `validation/fields.py` — new `parse_hours()` and `validate_hours()`
416
+ helpers. Accepted forms: numeric, decimal string (`"2.5"`), or
417
+ `"H:MM"` (`"2:30"` → 2.5h). Negative values, `H >= 60`, malformed
418
+ strings, and booleans are rejected with structured errors.
419
+ - `errors.py` — new `TimeEntryHoursInvalid` payload class.
420
+ - `schema/tracker.py` — `refresh_global_enumerations` now also
421
+ fetches `time_entry_activities` (cached under that meta key, same
422
+ 24h TTL as the other enumerations).
423
+ - `server.py` — registered the 4 tools; the 3 mutating ones honor
424
+ `REDMINE_MCP_READ_ONLY`.
425
+ - 25 new unit tests: 12 in `tests/test_validation.py` covering
426
+ `parse_hours` / `validate_hours` (valid + invalid + boundary forms),
427
+ 13 in `tests/test_tools/test_time_entries.py` covering each tool's
428
+ happy + validation-failure + propagation paths. 170 tests pass total;
429
+ ruff clean.
430
+ - Live smoke verified end-to-end:
431
+ created time entry #1 against test issue #2411 with `H:MM` hours +
432
+ resolved-by-name activity, listed it, rejected an invalid-hours
433
+ update client-side, applied a valid decimal-hours update, then
434
+ deleted the entry.
435
+
436
+ ### Added (continued)
437
+ - v0.2 first tool: `redmine_download_attachment` (Redmine
438
+ #2376).
439
+ - `tools/attachments.py` — `download_attachment` runs Redmine's
440
+ two-step download (GET `/attachments/{id}.json` for metadata, then
441
+ GET `/attachments/download/{id}/{filename}` for the bytes). Validates
442
+ downloaded byte count against the metadata's `filesize` BEFORE
443
+ writing — short reads are surfaced as `attachment_size_mismatch`
444
+ rather than silently saving a partial file.
445
+ - New `_is_save_path_allowed()` helper distinguishes target-not-yet-
446
+ existent from `_is_path_allowed`'s upload semantics: parent must
447
+ exist + be under the allowlist, target may not exist (or
448
+ `overwrite=True`). Symlinks are resolved on the parent before the
449
+ allowlist comparison so a symlinked decoy parent that points
450
+ outside the allowlist is rejected.
451
+ - `client.py` — `_request` extended with `binary: bool = False`; new
452
+ `get_binary()` method returns the raw response body (used for
453
+ attachment fetches that aren't JSON).
454
+ - `errors.py` — `AttachmentPathDenied` extended with two new reasons
455
+ (`parent_missing`, `exists_no_overwrite`) for the save-side checks.
456
+ - `server.py` — registered `redmine_download_attachment` (read-only;
457
+ no `write=True` flag because nothing in Redmine changes).
458
+ - 13 unit tests added to `tests/test_tools/test_attachments.py`
459
+ (6 cover `_is_save_path_allowed` directly including symlink-parent
460
+ rejection; 7 cover the tool itself including size-mismatch and
461
+ overwrite semantics). 136 tests pass total.
462
+ - Live smoke verified end-to-end:
463
+ downloaded attachment id 8 (the file uploaded during Phase 5 smoke
464
+ to test issue #2411), confirmed bytes match, confirmed the
465
+ no-overwrite and outside-allowlist rejections fire with the right
466
+ reason codes.
467
+
468
+ ### Added (continued)
469
+ - Phase 5: Comments + attachments (3 new tools — all 13 v0.1 tools now live).
470
+ - `tools/comments.py` — `add_comment` (direct PUT with `notes` /
471
+ `private_notes`; rejects empty/whitespace notes client-side to avoid
472
+ no-op `updated_on` bumps) and `get_journals` (read-only, lifts
473
+ `journals` to a top-level field).
474
+ - `tools/attachments.py` — `upload_attachment` (path-restricted via
475
+ `Config.allowed_directories`, two-step Redmine flow: POST
476
+ `/uploads.json` then optional PUT `/issues/{id}.json` with the
477
+ uploads array). Path safety check resolves symlinks before the
478
+ allowlist comparison so a symlink under `/tmp` pointing at
479
+ `/etc/shadow` is rejected. On a successful upload + failed attach,
480
+ the response carries the upload token so the caller can retry the
481
+ attach without re-reading the bytes.
482
+ - `client.py` — `_request` extended with `content` and `headers`
483
+ parameters; new `post_binary()` method for the
484
+ `Content-Type: application/octet-stream` body shape Redmine's
485
+ `/uploads.json` requires.
486
+ - `errors.py` — added `AttachmentPathDenied` payload class
487
+ (distinguishes `outside_allowlist` vs `not_a_file` reasons).
488
+ - `server.py` — registered the 3 new MCP tools. The attachment tool
489
+ pulls `Config.allowed_directories` at registration time so the tool
490
+ function doesn't need a config singleton import.
491
+ - 21 unit tests across `tests/test_tools/test_comments.py` (9) and
492
+ `tests/test_tools/test_attachments.py` (12 — 5 cover the path-safety
493
+ helper directly including symlink-escape rejection). 123 tests pass
494
+ total; ruff clean.
495
+
496
+ ### Added
497
+ - Phase 4: Issue lifecycle (5 new tools — 10 of the 13 v0.1 tools live).
498
+ - `tools/issues.py` — `get_issue`, `create_issue`, `update_issue`,
499
+ `close_issue`, `search_issues`. Each registered as an `@mcp.tool()`
500
+ handler in `server.py` via the existing `_wrap()` helper.
501
+ - `_wrap(..., write=True)` short-circuits write tools with
502
+ `ReadOnlyModeError` when `REDMINE_MCP_READ_ONLY=true`.
503
+ - `update_issue` is the marquee tool. On a status-changing call it:
504
+ pre-flights against the cache (`is_disallowed` short-circuits with a
505
+ `WorkflowTransitionDisallowed` payload populated by `allowed_next`),
506
+ sends the PUT, and records the outcome — `allowed` on success;
507
+ `disallowed` with the captured error text on a status-related 422.
508
+ The cache learns the workflow graph with each call.
509
+ - `close_issue` resolves the closed status from cached
510
+ `issue_statuses` (`is_closed=true`, falls back to id 5) and
511
+ repackages a workflow rejection with closure-specific framing.
512
+ - `search_issues` accepts substring query (Redmine `subject=~`) plus
513
+ optional project/status filters; passes through `"open"`/`"closed"`/
514
+ `"*"` special tokens case-sensitively so a named status like
515
+ `"Closed"` still resolves through the cache.
516
+ - Helpers in `issues.py` resolve project/tracker/priority/status
517
+ references against the cache, populating it on miss
518
+ (`describe_project`, `fetch_all_trackers`,
519
+ `refresh_global_enumerations`).
520
+ - 19 unit tests in `tests/test_tools/test_issues.py` covering each
521
+ tool's happy path plus 1–2 validation-failure paths (FakeClient
522
+ pattern, no network). 102 tests pass total.
523
+
524
+ ### Changed
525
+ - `server.py` no longer uses `from __future__ import annotations`.
526
+ FastMCP's `add_tool` introspects parameter annotations via
527
+ `inspect.signature(...).parameters[...].annotation` and runs
528
+ `issubclass(...)` on them; under PEP 563 the annotations are strings,
529
+ which fails the check. Removing the future import lets every tool
530
+ (Phases 1–4) register correctly. Local-variable annotations still
531
+ use PEP 604 union syntax (Python 3.10+ native).
532
+
533
+ ### Added
534
+ - Phase 3: Validation layer.
535
+ - `validation/transitions.py` — `is_disallowed`, `allowed_next`, `has_any_observation` (cache-backed reactive lookups; aggregates across role ids; includes role `0` for global-admin observations).
536
+ - `validation/fields.py` — `validate_required` (base required fields for create), `validate_custom_fields` (shape + optional id-allowlist).
537
+ - `validation/permissions.py` — `is_admin`, `role_names_for_project`, `require_role` (admin bypass + named-role allowlist).
538
+ - `errors.py` — added `WorkflowTransitionDisallowed`, `RequiredFieldMissing`, `CustomFieldUnknown`, `CustomFieldShapeError`, `RoleNotAuthorized` payload classes with structured `extra` fields and human-readable `hint`s.
539
+ - 24 unit tests (`test_validation.py`) covering all three validators + the error payload shapes.
540
+ - `pyproject.toml` per-file ruff override: N818 disabled in `errors.py` (the payload classes aren't Python exceptions).
541
+
542
+ ### Added (continued)
543
+ - Phase 2: Schema fetchers + 4 new discovery tools.
544
+ - `schema/tracker.py` — `fetch_all_trackers`, `describe_tracker` (enriches with global statuses + priorities + observed workflow graph).
545
+ - `schema/project.py` — `describe_project` (cache-backed), `list_projects` (paginated, optional client-side substring filter).
546
+ - `schema/workflow.py` — `fetch_current_user`, `role_ids_for_project`, `record_outcome` (writes per-role observations).
547
+ - `tools/discovery.py` — added `describe_tracker`, `describe_project`, `list_projects`, `invalidate_cache`.
548
+ - `server.py` — registered the 4 new MCP tools (now 5 of the 13 v0.1 tools live).
549
+ - `cache/migrations.py` v2 — added `outcome`, `observation_count`, `last_error_text` columns to `workflow_transitions`; renamed `fetched_at` → `observed_at`.
550
+ - `cache/schema_db.py` — added `get_tracker_by_name`, `resolve_tracker`, `get_meta_json`/`put_meta_json` (TTL'd JSON blobs in cache_meta), `record_workflow_observation`, `get_workflow_observation`, `list_workflow_observations`.
551
+
552
+ ### Changed
553
+ - Workflow validation pivoted from **pre-flight** to **reactive observation** because Redmine's `/workflows.json` returns 403 even for global admins. See `docs/workflow-validation.md` for the new design — the cache learns the workflow graph by recording the outcome of every status-change attempt, surfaces it via `describe_tracker(include_observations=true)`, and short-circuits known-disallowed transitions on subsequent calls.
554
+
555
+ ### Added (continued)
556
+ - 24 new unit tests across `test_cache.py` (10 added: meta_json, workflow observations, resolve_tracker) and `test_schema.py` (12 new: tracker/project/workflow fetchers via a FakeClient stand-in). 59 tests passing total.
557
+
558
+ - Phase 1: Core plumbing.
559
+ - `secrets.py` — triple-pattern loader for `~/.claude/secrets.md` (`REDMINE_API_KEY=`, `redmine_api_key:`, `TROUBLE_API_KEY=`).
560
+ - `config.py` — `Config` dataclass with `from_env()` factory; parses all `REDMINE_*` and `REDMINE_MCP_*` env vars; `require_api_key()` helper.
561
+ - `errors.py` — `StructuredError`, `RedmineAPIError`, `ReadOnlyModeError`.
562
+ - `client.py` — async `RedmineClient` (httpx-backed, retry on 5xx, `paginate()` async generator).
563
+ - `cache/migrations.py` — schema-version-stamped DDL for 5 cache tables.
564
+ - `cache/schema_db.py` — `SchemaCache` with TTL enforcement, auth-fingerprint reconciliation, `invalidate(scope=...)`.
565
+ - `tools/discovery.py` — `redmine_list_trackers` (smoke-test entry).
566
+ - `server.py` — FastMCP entrypoint, registered with the user-scoped MCP config.
567
+ - 35 unit tests across `test_secrets.py`, `test_config.py`, `test_cache.py` (all passing).
568
+ - End-to-end smoke test: `claude mcp list` reports the server connected; `redmine_list_trackers` round-trips against `http://127.0.0.1:8281`.
569
+ - Phase 0: Project scaffolding, MIT license, README, pyproject.toml.
570
+ - Empty module skeleton for `src/redmine_mcp/` with package boundaries (cache, schema, validation, tools).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Léon "Avic" Simmons
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.