Skip to content

feat: add tag CRUD tools (revisits #9) - #37

Open
madisonrickert wants to merge 4 commits into
verygoodplugins:mainfrom
madisonrickert:feat/tag-crud-v2
Open

feat: add tag CRUD tools (revisits #9)#37
madisonrickert wants to merge 4 commits into
verygoodplugins:mainfrom
madisonrickert:feat/tag-crud-v2

Conversation

@madisonrickert

@madisonrickert madisonrickert commented May 2, 2026

Copy link
Copy Markdown
Contributor

Reintroduces tag CRUD per the closing note on #9, with the requested tests and cache invalidation against the current bounded-bucket CacheManager.

Note

Tracking #36. PR was authored against current main (v1.1.0). I see #36 introduces a createTogglServer() factory and a 50% Vitest coverage gate on src/**/*.ts. Happy to rebase onto post-#36 main and add handler tests using the new tests/mcp-server.test.ts pattern if that's useful — or merge in either order, your call. Local cherry-pick onto feat/self-host-streamable-http was clean for commits 1–3; commit 4's conflict is mechanical (porting tool defs + cases into the new factory structure).

What's added

Four MCP tools:

  • toggl_list_tags — read; cache-backed
  • toggl_create_tag — write; idempotentHint: false
  • toggl_update_tag — write; rename in place; idempotentHint: true
  • toggl_delete_tag — write; destructiveHint: true

Each write tool calls cache.invalidateWorkspaceTags(workspaceId) after a successful API call. The invalidation is workspace-scoped: it drops tagsByWorkspace[wid] and any single-tag entries with that workspace_id, leaving unrelated workspaces' caches untouched. Invalidation runs only on success, so cache state stays consistent with Toggl when a write is rejected.

All four tools resolve workspace via the existing resolveWorkspaceForTool helper, returning structured WorkspaceResolutionError payloads when ambiguous.

Includes a small request() fix

Tag DELETE returns HTTP 200 with content-length: 0 per the Toggl API docs. The existing request() short-circuited only on 204, so it called response.json() on the empty body, threw, retried (no noRetry flag on the JSON parse error), and the second attempt got a real 404 because the first call had already succeeded server-side — surfacing as a misleading "Tag was not found" error to callers despite Toggl having completed the deletion.

Fix: treat HTTP 200 with content-length: 0 (or empty body when Content-Length is absent) the same as 204. Behavior unchanged for non-empty success responses on every other endpoint. As a side benefit the same fix covers deleteTimeEntry, which has the same Toggl response shape.

Happy to split this fix into a separate PR if preferred — it stands alone, but tag DELETE depends on it being in place.

Commits

Four logical commits, each independently building and passing tests:

  1. fix(api): handle Toggl 200/empty-body responses
  2. feat(api): add tag CRUD methods to TogglAPI client
  3. feat(cache): add workspace tag invalidation
  4. feat(tools): expose tag CRUD as MCP tools

Verification

  • npm test — 36/36 passing (5 new tests: API verb shape per CRUD method, 4xx no-retry contract, 200/empty-body regression, cache invalidation refetch + workspace-isolation)
  • npm run build — clean
  • npm run lint — no new warnings
  • Smoke-tested end-to-end against a real Toggl workspace: create → list (verify visible) → rename (verify ID preserved, name updated) → delete → verify cleanup. The 200/empty-body bug was discovered during this smoke test and the fix verified live.

Coverage with #36's proposed thresholds

I ran npm run test:coverage against the proposed vitest.config.ts from #36 (50% threshold across all metrics, include: ['src/**/*.ts']). Numbers, this PR vs current main:

Metric main this PR delta
Statements 41.29% 41.40% +0.11
Branches 37.87% 38.96% +1.09
Functions 48.70% 50.62% +1.92
Lines 42.51% 42.57% +0.06

Every metric improves; functions cross the threshold.

Scope

Tag CRUD only. No project/client/time-entry write tools, no SDK upgrade, no opportunistic refactoring.

madisonrickert and others added 4 commits May 2, 2026 10:45
Toggl returns HTTP 200 with content-length: 0 (not 204) on some write
endpoints, including DELETE /workspaces/{wid}/tags/{tid} and
DELETE /workspaces/{wid}/time_entries/{tid}, per the official Toggl
Engineering API docs. The existing request() short-circuited only on
204, so it called response.json() on the empty body, which threw and
triggered the retry loop without noRetry. The retried call then got a
real 404 because the first call had already succeeded server-side,
surfacing the misleading "Tag was not found" / "not found" errors to
callers despite the operation having completed.

Treat 200 with content-length: 0 (or empty body when Content-Length is
absent) the same as 204. Behavior unchanged for non-empty success
responses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add createTag/updateTag/deleteTag against the documented Toggl v9
endpoints:

  POST   /workspaces/{wid}/tags
  PUT    /workspaces/{wid}/tags/{tid}
  DELETE /workspaces/{wid}/tags/{tid}

deleteTag returns void: Toggl emits HTTP 200 with empty body, which
the upstream request() change in the prior commit now treats as
successful. Tests pin endpoint/method/body shape per verb and the
4xx-no-retry contract for write operations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add invalidateWorkspaceTags(workspaceId) to CacheManager. Drops the
workspace tag-list entry and any single-tag entries belonging to that
workspace, so the next read refetches from Toggl. Single-workspace
scoping preserves cached state for unrelated workspaces.

Tag CRUD tools call this after each successful create/update/delete
to keep listings consistent with Toggl. Invalidation runs only on
success: if the API rejects a write, server state is unchanged and
the cache is still accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add four tools backed by the Toggl tag CRUD methods and workspace tag
cache invalidation:

  toggl_list_tags    (read; cache-backed)
  toggl_create_tag   (write; idempotentHint: false)
  toggl_update_tag   (write; idempotentHint: true; rename in place)
  toggl_delete_tag   (write; destructiveHint: true)

All four resolve workspace_id via resolveWorkspaceForTool, returning
WorkspaceResolutionError when ambiguous. Write tools invalidate the
workspace tag cache only after a successful API call. Runtime input
checks guard against type-coerced or whitespace-only inputs that the
JSON schema does not catch at the tool boundary.

README adds a Tag Management section and a list-tags row under
Lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jack-arturo

Copy link
Copy Markdown
Member

Thanks for the original tag CRUD work here. I created a maintainer refresh branch/PR at #59 with attribution preserved.

What changed in the refresh:

  • stacked on feat(toggl): add time entry task tools #58 so tag CRUD reuses the shared successful-empty-body/write behavior instead of duplicating it here
  • added handler/API/cache tests for list/create/update/delete tags
  • normalized Toggl tag responses across raw arrays, items envelopes, and bare tag objects
  • kept cache invalidation scoped to the affected workspace after successful tag writes

I’m leaving this PR open for traceability while #59 moves through review.

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.

2 participants