Skip to content

feat(activities): let create_activity set to/cc/bcc recipient roles - #27

Merged
SelerityMichael merged 2 commits into
mainfrom
fix/create-activity-recipients
Oct 14, 2026
Merged

SelerityMichael merged 2 commits into
mainfrom
fix/create-activity-recipients

Conversation

@SelerityMichael

Copy link
Copy Markdown
Member

Summary

Fixes #23. accelo_create_activity had no way to set a recipient role (to/cc/bcc) when creating an email or meeting activity. The only party parameters were owner_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 as bcc, never to.

What changed

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.

src/accelo_mcp/client.py (minimal, backward-compatible)

  • 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 error handling is preserved for both paths.

src/accelo_mcp/tools/activities.py

  • 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 — e.g. to={"staff": [10]}, cc={"affiliation": [13, 14]}, bcc={"emails": ["x@y.com"]}.
  • A _validate_role helper returns a structured invalid_recipient error on a bad shape (and rejects 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.

Testing

Assertions compare parsed JSON values (no URL-substring checks, so no CodeQL py/incomplete-url-substring-sanitization regression):

  • to={"staff":[10]} — asserts the POST is JSON (application/json) and the body carries to.staff == [10] plus the core fields.
  • cc={"affiliation":[13,14]} + bcc={"emails":["a@b.com"]} — asserts the JSON body carries both.
  • No recipients — asserts the request still uses the form-encoded path, proving existing callers are undisturbed.
  • Invalid role shape — asserts a structured error with no HTTP call made.
  • Client tests: json= path sends application/json; data= path still form-encoded; JSON body re-sent on a 401 refresh.

53 activities + client tests pass; ruff check and ruff format --check both 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.

@SelerityMichael
SelerityMichael requested a review from a team as a code owner October 14, 2026 08:40

@amazon-q-developer amazon-q-developer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/accelo_mcp/tools/activities.py Outdated
# 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 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.

Suggested change
body = dict(data)
json_body = dict(data)

Comment thread src/accelo_mcp/tools/activities.py Outdated
Comment on lines +252 to +253
body[role_name] = role_value
return await client.post("/activities", json=body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 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.

Suggested change
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)

Comment thread src/accelo_mcp/tools/activities.py Outdated
Comment on lines +30 to +31
if not isinstance(items, list):
return f"'{name}.{key}' must be a list"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 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.

Suggested change
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"

Comment thread src/accelo_mcp/client.py Outdated
Comment on lines 99 to 107
# 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,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 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.

Michael Dixon added 2 commits October 14, 2026 20:19
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).
@SelerityMichael
SelerityMichael force-pushed the fix/create-activity-recipients branch from 389bb6b to d524f22 Compare October 14, 2026 09:19
@SelerityMichael

Copy link
Copy Markdown
Member Author

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

  1. Variable shadowing — the local body = dict(data) in the recipient block is renamed to json_body throughout. Pure rename, no behaviour change.
  2. Empty recipient lists — _validate_role now rejects an empty list (to={"staff": []} returns an invalid_recipient error and makes no HTTP call); non-empty lists are unchanged.
  3. Content-Type header — _request now defaults to Authorization-only, sets application/x-www-form-urlencoded only when a form data body is present, and lets httpx set the JSON content-type for the json= path (removing the duplicate application/json header). Bodyless GET/DELETE carry no form content-type. Auth/retry/error handling unchanged.

New tests assert exactly one application/json header on the json path, the form content-type is preserved on the data path, no form content-type on a GET, and the empty-list rejection. 57 activities+client tests pass; ruff clean.

@SelerityMichael
SelerityMichael merged commit 178857b into main Oct 14, 2026
6 checks passed
@SelerityMichael
SelerityMichael deleted the fix/create-activity-recipients branch October 14, 2026 09:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

create_activity cannot set recipient role (to/cc/bcc) — recipient becomes author or bcc

1 participant