Repository navigation
feat(activities): let create_activity set to/cc/bcc recipient roles - #27
Conversation
There was a problem hiding this comment.
Review Summary
This PR correctly implements the core goal — adding to/cc/bcc recipient role support to accelo_create_activity via a JSON body path, with a backward-compatible fallback to form-encoding when no recipients are supplied. The client-layer changes to thread json= through retries are sound.
Blocking Issues Found
1. Variable shadowing causes a latent NameError (activities.py lines 241–253)
The local variable body = dict(data) on line 241 shadows the outer body: str | None parameter. While the activity body string is already written into data["body"] before this point, the name collision is a bug waiting to happen and currently makes the code misleading. More critically, if the variable is ever renamed (e.g., as a fix), lines 252 and 253 must be updated atomically — I've flagged both together. The fix is to use a distinct name like json_body throughout lines 241–253.
2. Empty list is accepted as a valid recipient list (activities.py line 30–31)
_validate_role checks isinstance(items, list) but does not reject empty lists. to={"staff": []} passes validation and would be sent to Accelo as a JSON body with an empty recipient array, producing either an API error or a silently recipient-less activity. A not items check should be added alongside the type check.
3. Duplicate / incorrect Content-Type header in _request (client.py lines 99–107)
The Content-Type header is unconditionally set for every request, including GET and DELETE (which have no body). When json= is used, both this explicit Content-Type: application/json and httpx's own auto-set header from the json= kwarg are emitted, producing a duplicate header. The header should only be set when a body is present.
You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.
| # body carrying the activity fields alongside the to/cc/bcc objects. | ||
| roles = {"to": to, "cc": cc, "bcc": bcc} | ||
| if any(value is not None for value in roles.values()): | ||
| body = dict(data) |
There was a problem hiding this comment.
🛑 Logic Error: The local variable body on line 241 shadows the body parameter of accelo_create_activity (the activity body string). While the string value is already written into data["body"] before this point (line 213), this shadowing makes the code fragile — any future access to the body parameter after line 241 would silently read the dict instead of the string. Rename the local variable to avoid the collision.
| body = dict(data) | |
| json_body = dict(data) |
| body[role_name] = role_value | ||
| return await client.post("/activities", json=body) |
There was a problem hiding this comment.
🛑 Logic Error: If body is renamed to json_body on the line above, the subsequent references on lines 252–253 must be updated to match. These two lines still reference the old variable name and will raise a NameError at runtime.
| body[role_name] = role_value | |
| return await client.post("/activities", json=body) | |
| json_body[role_name] = role_value | |
| return await client.post("/activities", json=json_body) |
| if not isinstance(items, list): | ||
| return f"'{name}.{key}' must be a list" |
There was a problem hiding this comment.
🛑 Logic Error: _validate_role accepts empty lists for staff, affiliation, and emails as valid. A caller passing to={"staff": []} would pass validation and send {"to": {"staff": []}} to Accelo, which will either return an API error or silently produce an activity with no to recipient — both incorrect outcomes. Add an empty-list check alongside the isinstance(items, list) guard.
| if not isinstance(items, list): | |
| return f"'{name}.{key}' must be a list" | |
| if not isinstance(items, list) or not items: | |
| return f"'{name}.{key}' must be a non-empty list" |
| # Send a JSON body when ``json`` is provided; otherwise keep the | ||
| # existing form-encoded path byte-for-byte. | ||
| content_type = ( | ||
| "application/json" if json is not None else "application/x-www-form-urlencoded" | ||
| ) | ||
| headers = { | ||
| "Authorization": f"Bearer {token}", | ||
| "Content-Type": "application/x-www-form-urlencoded", | ||
| "Content-Type": content_type, | ||
| } |
There was a problem hiding this comment.
🛑 Logic Error: The Content-Type header is set manually to application/x-www-form-urlencoded even for GET and DELETE requests (which carry no body). httpx does not send a Content-Type header for bodyless requests by default, but this code forces it for every method. Additionally, when json= is provided, both this manual Content-Type: application/json header and httpx's own auto-set Content-Type: application/json (from the json= kwarg) will be sent, resulting in a duplicate header that may confuse intermediaries. Consider only setting the Content-Type header when a body is present.
Fixes #23: create_activity had no way to address an email (or meeting) activity TO a staff member or contact. The only party parameters were owner_type/owner_id, and the owner maps to the sender (from), so a named party either became the author or landed as bcc, never to. Accelo's recipient interactions must be sent as a JSON body with to/cc/bcc objects, which the form-only HTTP client could not express, so this touches the client as well as the tool: - client: AcceloClient.post()/_request() gain an optional json= parameter. When provided, the body is sent as application/json; the existing data= form-encoded path is unchanged. The json body is carried through the 401-refresh and 5xx retry recursions, and 429/meta.status handling is preserved for both paths. - activities: accelo_create_activity gains to/cc/bcc dict parameters whose keys are staff/affiliation (lists of ids) or emails (list of strings), mirroring Accelo's documented shape. A _validate_role helper returns a structured invalid_recipient error on a bad shape (rejecting booleans as ids) rather than silently dropping. When any role is present the request is sent as one JSON body (activity fields + to/cc/bcc + _fields); a recipient named under 'to' is addressed as 'to', not bcc. With no recipients the existing form-encoded path is byte-for-byte unchanged. - docstring: documents the owner-vs-recipient distinction (owner = from/ author; to/cc/bcc = recipients) and that raw emails create no interact object on Accelo's side. Tests (asserting on parsed JSON, not URL substrings): to.staff JSON body + content-type; cc.affiliation + bcc.emails; no-recipient form path unchanged; invalid role shape returns a structured error with no HTTP call; and client tests for the json path vs form path and json re-sent on 401 refresh. 53 activities+client tests pass; ruff check and format clean. No version bump (release batched).
Addresses three findings from the PR #27 review: - Rename the shadowing local 'body = dict(data)' to 'json_body' in the recipient block so it no longer collides with the body string parameter. Pure rename, no behaviour change. - Reject empty recipient lists in _validate_role: to={'staff': []} now returns an invalid_recipient error instead of sending a recipient-less payload to Accelo. Non-empty lists are unchanged. - Build request headers with Authorization only by default; set Content-Type: application/x-www-form-urlencoded only when a form 'data' body is present, and let httpx set the JSON content-type for the 'json' path (removing a duplicate application/json header). Bodyless GET/DELETE now carry no form content-type. Auth/retry/error handling unchanged. Tests: empty-list rejection (invalid_recipient, no POST), exactly one application/json header on the json path, form content-type preserved on the data path, and no form content-type on a GET. 57 activities+client tests pass; ruff clean. No version bump (release batched).
389bb6b to
d524f22
Compare
|
Thanks — all three findings were valid and are fixed in d524f22 (and the branch is now rebased onto current main, which has #25 and #26):
New tests assert exactly one |
Summary
Fixes #23.
accelo_create_activityhad no way to set a recipient role (to/cc/bcc) when creating an email or meeting activity. The only party parameters wereowner_type/owner_id, and the owner maps to the activity's sender (from) — so you could not author an activity addressed to a named staff member or contact: the party either became the author, or landed asbcc, neverto.What changed
Accelo's recipient interactions must be sent as a JSON body with
to/cc/bccobjects, which the form-only HTTP client could not express. So this touches the client as well as the tool.src/accelo_mcp/client.py(minimal, backward-compatible)AcceloClient.post()/_request()gain an optionaljson=parameter. When provided, the body is sent asapplication/json; the existingdata=form-encoded path is unchanged. The JSON body is carried through the 401-refresh and 5xx retry recursions, and 429 /meta.statuserror handling is preserved for both paths.src/accelo_mcp/tools/activities.pyaccelo_create_activitygainsto/cc/bccdict parameters whose keys arestaff/affiliation(lists of ids) oremails(list of strings), mirroring Accelo's documented shape — e.g.to={"staff": [10]},cc={"affiliation": [13, 14]},bcc={"emails": ["x@y.com"]}._validate_rolehelper returns a structuredinvalid_recipienterror on a bad shape (and rejects booleans as ids) rather than silently dropping._fields); a recipient named undertois addressed asto, not bcc. With no recipients, the existing form-encoded path is byte-for-byte unchanged.from/author; to/cc/bcc = recipients) and that raw emails create no interact object on Accelo's side.Testing
Assertions compare parsed JSON values (no URL-substring checks, so no CodeQL
py/incomplete-url-substring-sanitizationregression):to={"staff":[10]}— asserts the POST is JSON (application/json) and the body carriesto.staff == [10]plus the core fields.cc={"affiliation":[13,14]}+bcc={"emails":["a@b.com"]}— asserts the JSON body carries both.json=path sendsapplication/json;data=path still form-encoded; JSON body re-sent on a 401 refresh.53 activities + client tests pass;
ruff checkandruff format --checkboth clean.Release
No version bump in this PR — the release is batched. A single consolidated version bump + release note will be cut once #16, #22 and #23 have all merged.
Closes #23.