Skip to content

Commit 5dddd55

Browse files
feat: add first-class tag management tools
Add CRUD MCP tools for Toggl tags (list, create, update, delete) with cache invalidation on mutations, matching the existing pattern for projects and clients. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8500a42 commit 5dddd55

File tree

3 files changed

+196
-2
lines changed

3 files changed

+196
-2
lines changed

src/cache-manager.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,23 @@ export class CacheManager {
377377

378378
return hydrated;
379379
}
380-
380+
381+
// Invalidate a single tag from cache
382+
invalidateTag(tagId: number): void {
383+
this.tags.delete(tagId);
384+
}
385+
386+
// Invalidate all tags for a workspace (used after create/update/delete)
387+
invalidateTagsForWorkspace(workspaceId: number): void {
388+
const toDelete: number[] = [];
389+
this.tags.forEach((entry, key) => {
390+
if (entry.data.workspace_id === workspaceId) {
391+
toDelete.push(key);
392+
}
393+
});
394+
toDelete.forEach(key => this.tags.delete(key));
395+
}
396+
381397
// Clear cache
382398
clearCache(): void {
383399
this.workspaces.clear();

src/index.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,77 @@ const tools: Tool[] = [
334334
}
335335
},
336336
},
337+
{
338+
name: 'toggl_list_tags',
339+
description: 'List tags for a workspace',
340+
inputSchema: {
341+
type: 'object',
342+
properties: {
343+
workspace_id: {
344+
type: 'number',
345+
description: 'Workspace ID (uses default if not provided)'
346+
}
347+
}
348+
},
349+
},
350+
{
351+
name: 'toggl_create_tag',
352+
description: 'Create a new tag in a workspace',
353+
inputSchema: {
354+
type: 'object',
355+
properties: {
356+
name: {
357+
type: 'string',
358+
description: 'Tag name'
359+
},
360+
workspace_id: {
361+
type: 'number',
362+
description: 'Workspace ID (uses default if not provided)'
363+
}
364+
},
365+
required: ['name']
366+
},
367+
},
368+
{
369+
name: 'toggl_update_tag',
370+
description: 'Rename an existing tag',
371+
inputSchema: {
372+
type: 'object',
373+
properties: {
374+
tag_id: {
375+
type: 'number',
376+
description: 'Tag ID to update'
377+
},
378+
name: {
379+
type: 'string',
380+
description: 'New tag name'
381+
},
382+
workspace_id: {
383+
type: 'number',
384+
description: 'Workspace ID (uses default if not provided)'
385+
}
386+
},
387+
required: ['tag_id', 'name']
388+
},
389+
},
390+
{
391+
name: 'toggl_delete_tag',
392+
description: 'Delete a tag from a workspace',
393+
inputSchema: {
394+
type: 'object',
395+
properties: {
396+
tag_id: {
397+
type: 'number',
398+
description: 'Tag ID to delete'
399+
},
400+
workspace_id: {
401+
type: 'number',
402+
description: 'Workspace ID (uses default if not provided)'
403+
}
404+
},
405+
required: ['tag_id']
406+
},
407+
},
337408
{
338409
name: 'toggl_list_clients',
339410
description: 'List clients for a workspace',
@@ -746,6 +817,101 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
746817
};
747818
}
748819

820+
case 'toggl_list_tags': {
821+
const workspaceId = args?.workspace_id || defaultWorkspaceId;
822+
if (!workspaceId) {
823+
throw new Error('Workspace ID required (set TOGGL_DEFAULT_WORKSPACE_ID or provide workspace_id)');
824+
}
825+
826+
const tags = await api.getTags(workspaceId as number);
827+
828+
return {
829+
content: [{
830+
type: 'text',
831+
text: JSON.stringify({
832+
workspace_id: workspaceId,
833+
count: tags.length,
834+
tags: tags.map(t => ({
835+
id: t.id,
836+
name: t.name
837+
}))
838+
}, null, 2)
839+
}]
840+
};
841+
}
842+
843+
case 'toggl_create_tag': {
844+
const workspaceId = args?.workspace_id || defaultWorkspaceId;
845+
if (!workspaceId) {
846+
throw new Error('Workspace ID required (set TOGGL_DEFAULT_WORKSPACE_ID or provide workspace_id)');
847+
}
848+
if (!args?.name) {
849+
throw new Error('Tag name is required');
850+
}
851+
852+
const tag = await api.createTag(workspaceId as number, args.name as string);
853+
cache.invalidateTagsForWorkspace(workspaceId as number);
854+
855+
return {
856+
content: [{
857+
type: 'text',
858+
text: JSON.stringify({
859+
success: true,
860+
message: 'Tag created',
861+
tag: { id: tag.id, name: tag.name, workspace_id: tag.workspace_id }
862+
}, null, 2)
863+
}]
864+
};
865+
}
866+
867+
case 'toggl_update_tag': {
868+
const workspaceId = args?.workspace_id || defaultWorkspaceId;
869+
if (!workspaceId) {
870+
throw new Error('Workspace ID required (set TOGGL_DEFAULT_WORKSPACE_ID or provide workspace_id)');
871+
}
872+
if (!args?.tag_id || !args?.name) {
873+
throw new Error('Tag ID and new name are required');
874+
}
875+
876+
const tag = await api.updateTag(workspaceId as number, args.tag_id as number, args.name as string);
877+
cache.invalidateTag(args.tag_id as number);
878+
879+
return {
880+
content: [{
881+
type: 'text',
882+
text: JSON.stringify({
883+
success: true,
884+
message: 'Tag updated',
885+
tag: { id: tag.id, name: tag.name, workspace_id: tag.workspace_id }
886+
}, null, 2)
887+
}]
888+
};
889+
}
890+
891+
case 'toggl_delete_tag': {
892+
const workspaceId = args?.workspace_id || defaultWorkspaceId;
893+
if (!workspaceId) {
894+
throw new Error('Workspace ID required (set TOGGL_DEFAULT_WORKSPACE_ID or provide workspace_id)');
895+
}
896+
if (!args?.tag_id) {
897+
throw new Error('Tag ID is required');
898+
}
899+
900+
await api.deleteTag(workspaceId as number, args.tag_id as number);
901+
cache.invalidateTag(args.tag_id as number);
902+
903+
return {
904+
content: [{
905+
type: 'text',
906+
text: JSON.stringify({
907+
success: true,
908+
message: 'Tag deleted',
909+
tag_id: args.tag_id
910+
}, null, 2)
911+
}]
912+
};
913+
}
914+
749915
case 'toggl_list_clients': {
750916
const workspaceId = args?.workspace_id || defaultWorkspaceId;
751917
if (!workspaceId) {

src/toggl-api.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,19 @@ export class TogglAPI {
157157
async getTag(workspaceId: number, tagId: number): Promise<Tag> {
158158
return this.request<Tag>('GET', `/workspaces/${workspaceId}/tags/${tagId}`);
159159
}
160-
160+
161+
async createTag(workspaceId: number, name: string): Promise<Tag> {
162+
return this.request<Tag>('POST', `/workspaces/${workspaceId}/tags`, { name });
163+
}
164+
165+
async updateTag(workspaceId: number, tagId: number, name: string): Promise<Tag> {
166+
return this.request<Tag>('PUT', `/workspaces/${workspaceId}/tags/${tagId}`, { name });
167+
}
168+
169+
async deleteTag(workspaceId: number, tagId: number): Promise<void> {
170+
await this.request<void>('DELETE', `/workspaces/${workspaceId}/tags/${tagId}`);
171+
}
172+
161173
// Time entry methods
162174
async getTimeEntries(params?: TimeEntriesRequest): Promise<TimeEntry[]> {
163175
let endpoint = '/me/time_entries';

0 commit comments

Comments
 (0)