From ac9908c2cb2fc6e9ef47f337db49fc36ed52eadc Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 7 Jul 2026 11:29:13 -0700 Subject: [PATCH 1/4] fix(microsoft-planner): align with live Graph API docs, add plan CRUD + categories - fix update_task never returning updated task (missing Prefer: return=representation) - fix wrong @odata.type on task assignments (missing leading #) - fix update_plan/update_plan_details/update_bucket/update_task_details crashing on 204 No Content responses to PATCH (Graph sometimes ignores Prefer header) - add .trim() on path IDs across tools invoked outside the block - add create_plan, update_plan, delete_plan, get_plan_details, update_plan_details tools (If-Match/etag handled correctly on update/delete) - add appliedCategories support on create_task/update_task - all new tools verified against Graph API Permissions tables to require only scopes we already request (Group.ReadWrite.All, Group.Read.All, Tasks.ReadWrite) - regenerate integration docs --- .../en/integrations/microsoft_planner.mdx | 109 +++++++- apps/sim/blocks/blocks/microsoft_planner.ts | 246 ++++++++++++++++-- .../tools/microsoft_planner/create_plan.ts | 111 ++++++++ .../tools/microsoft_planner/create_task.ts | 63 ++++- .../tools/microsoft_planner/delete_bucket.ts | 5 +- .../tools/microsoft_planner/delete_plan.ts | 100 +++++++ .../tools/microsoft_planner/delete_task.ts | 5 +- .../microsoft_planner/get_plan_details.ts | 100 +++++++ .../microsoft_planner/get_task_details.ts | 5 +- apps/sim/tools/microsoft_planner/index.ts | 10 + .../tools/microsoft_planner/list_buckets.ts | 5 +- .../tools/microsoft_planner/read_bucket.ts | 5 +- apps/sim/tools/microsoft_planner/read_plan.ts | 5 +- apps/sim/tools/microsoft_planner/types.ts | 62 ++++- .../tools/microsoft_planner/update_bucket.ts | 27 +- .../tools/microsoft_planner/update_plan.ts | 143 ++++++++++ .../microsoft_planner/update_plan_details.ts | 180 +++++++++++++ .../tools/microsoft_planner/update_task.ts | 35 ++- .../microsoft_planner/update_task_details.ts | 42 ++- apps/sim/tools/registry.ts | 10 + 20 files changed, 1214 insertions(+), 54 deletions(-) create mode 100644 apps/sim/tools/microsoft_planner/create_plan.ts create mode 100644 apps/sim/tools/microsoft_planner/delete_plan.ts create mode 100644 apps/sim/tools/microsoft_planner/get_plan_details.ts create mode 100644 apps/sim/tools/microsoft_planner/update_plan.ts create mode 100644 apps/sim/tools/microsoft_planner/update_plan_details.ts diff --git a/apps/docs/content/docs/en/integrations/microsoft_planner.mdx b/apps/docs/content/docs/en/integrations/microsoft_planner.mdx index 0138c2c582d..28fff265020 100644 --- a/apps/docs/content/docs/en/integrations/microsoft_planner.mdx +++ b/apps/docs/content/docs/en/integrations/microsoft_planner.mdx @@ -64,10 +64,13 @@ Create a new task in Microsoft Planner | --------- | ---- | -------- | ----------- | | `planId` | string | Yes | The ID of the plan where the task will be created \(e.g., "xqQg5FS2LkCe54tAMV_v2ZgADW2J"\) | | `title` | string | Yes | The title of the task \(e.g., "Review quarterly report"\) | -| `description` | string | No | The description of the task | | `dueDateTime` | string | No | The due date and time for the task in ISO 8601 format \(e.g., "2025-03-15T17:00:00Z"\) | +| `startDateTime` | string | No | The start date and time for the task in ISO 8601 format \(e.g., "2025-03-10T09:00:00Z"\) | +| `priority` | number | No | The priority of the task \(0-10, where 0 is urgent and 10 is low\) | +| `percentComplete` | number | No | The percentage of task completion \(0-100\) | | `assigneeUserId` | string | No | The user ID to assign the task to \(e.g., "e82f74c3-4d8a-4b5c-9f1e-2a6b8c9d0e3f"\) | | `bucketId` | string | No | The bucket ID to place the task in \(e.g., "hsOf2dhOJkC6Fey9VjDg1JgAC9Rq"\) | +| `appliedCategories` | string | No | Comma-separated category labels to apply to the task, e.g. "category1,category3" \(up to category1-category25, plan-defined color labels\) | #### Output @@ -97,6 +100,7 @@ Update a task in Microsoft Planner | `percentComplete` | number | No | The percentage of task completion \(0-100\) | | `priority` | number | No | The priority of the task \(0-10\) | | `assigneeUserId` | string | No | The user ID to assign the task to \(e.g., "e82f74c3-4d8a-4b5c-9f1e-2a6b8c9d0e3f"\) | +| `appliedCategories` | string | No | Comma-separated category labels to apply to the task, e.g. "category1,category3" \(up to category1-category25, plan-defined color labels\) | #### Output @@ -170,6 +174,109 @@ Get details of a specific Microsoft Planner plan | ↳ `planId` | string | Plan ID | | ↳ `planUrl` | string | Microsoft Graph API URL for the plan | +### `microsoft_planner_create_plan` + +Create a new Microsoft Planner plan owned by a Microsoft 365 group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `groupId` | string | Yes | The ID of the Microsoft 365 group that will own the plan \(e.g., "ebf3b108-5234-4e22-b93d-656d7dae5874"\). The current user must be a member of this group. | +| `title` | string | Yes | The title of the plan | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the plan was created successfully | +| `plan` | object | The created plan object with all properties | +| `metadata` | object | Metadata including planId and groupId | +| ↳ `planId` | string | Created plan ID | +| ↳ `groupId` | string | Owning Microsoft 365 group ID | + +### `microsoft_planner_update_plan` + +Rename a Microsoft Planner plan + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `planId` | string | Yes | The ID of the plan to update \(e.g., "xqQg5FS2LkCe54tAMV_v2ZgADW2J"\) | +| `etag` | string | Yes | The ETag value from the plan to update \(If-Match header\) | +| `title` | string | Yes | The new title of the plan | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the plan was updated successfully | +| `plan` | object | The updated plan object with all properties | +| `metadata` | object | Metadata including planId | +| ↳ `planId` | string | Updated plan ID | + +### `microsoft_planner_get_plan_details` + +Get detailed information about a plan including category descriptions and sharing + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `planId` | string | Yes | The ID of the plan \(e.g., "xqQg5FS2LkCe54tAMV_v2ZgADW2J"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the plan details were retrieved successfully | +| `planDetails` | object | The plan details including categoryDescriptions and sharedWith | +| `etag` | string | The ETag value for this plan details resource | +| `metadata` | object | Metadata including planId | +| ↳ `planId` | string | Plan ID | + +### `microsoft_planner_update_plan_details` + +Update a plan's category (color label) descriptions and shared-with user list in Microsoft Planner + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `planId` | string | Yes | The ID of the plan \(e.g., "xqQg5FS2LkCe54tAMV_v2ZgADW2J"\) | +| `etag` | string | Yes | The ETag value from the plan details to update \(If-Match header\) | +| `categoryDescriptions` | object | No | Category label names as a JSON object, e.g. \{"category1": "Blocked", "category2": "At Risk"\}. Set a value to null to clear a label. | +| `sharedWith` | object | No | User IDs to share the plan with as a JSON object, e.g. \{"<user-id>": true\}. Set a value to false to unshare. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the plan details were updated successfully | +| `planDetails` | object | The updated plan details object with categoryDescriptions and sharedWith | +| `metadata` | object | Metadata including planId | +| ↳ `planId` | string | Plan ID | + +### `microsoft_planner_delete_plan` + +Delete a Microsoft Planner plan + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `planId` | string | Yes | The ID of the plan to delete \(e.g., "xqQg5FS2LkCe54tAMV_v2ZgADW2J"\) | +| `etag` | string | Yes | The ETag value from the plan to delete \(If-Match header\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the plan was deleted successfully | +| `deleted` | boolean | Confirmation of deletion | +| `metadata` | object | Additional metadata | + ### `microsoft_planner_list_buckets` List all buckets in a Microsoft Planner plan diff --git a/apps/sim/blocks/blocks/microsoft_planner.ts b/apps/sim/blocks/blocks/microsoft_planner.ts index 4c6e800f5de..b6c868bae1d 100644 --- a/apps/sim/blocks/blocks/microsoft_planner.ts +++ b/apps/sim/blocks/blocks/microsoft_planner.ts @@ -23,6 +23,9 @@ interface MicrosoftPlannerBlockParams { checklist?: string references?: string previewType?: string + appliedCategories?: string + categoryDescriptions?: string + sharedWith?: string [key: string]: string | number | boolean | undefined } @@ -50,6 +53,11 @@ export const MicrosoftPlannerBlock: BlockConfig = { { label: 'Delete Task', id: 'delete_task' }, { label: 'List Plans', id: 'list_plans' }, { label: 'Read Plan', id: 'read_plan' }, + { label: 'Create Plan', id: 'create_plan' }, + { label: 'Update Plan', id: 'update_plan' }, + { label: 'Get Plan Details', id: 'get_plan_details' }, + { label: 'Update Plan Details', id: 'update_plan_details' }, + { label: 'Delete Plan', id: 'delete_plan' }, { label: 'List Buckets', id: 'list_buckets' }, { label: 'Read Bucket', id: 'read_bucket' }, { label: 'Create Bucket', id: 'create_bucket' }, @@ -91,11 +99,30 @@ export const MicrosoftPlannerBlock: BlockConfig = { mode: 'basic', condition: { field: 'operation', - value: ['create_task', 'read_task', 'read_plan', 'list_buckets', 'create_bucket'], + value: [ + 'create_task', + 'read_task', + 'read_plan', + 'list_buckets', + 'create_bucket', + 'update_plan', + 'delete_plan', + 'get_plan_details', + 'update_plan_details', + ], }, required: { field: 'operation', - value: ['read_plan', 'list_buckets', 'create_bucket', 'create_task'], + value: [ + 'read_plan', + 'list_buckets', + 'create_bucket', + 'create_task', + 'update_plan', + 'delete_plan', + 'get_plan_details', + 'update_plan_details', + ], }, }, @@ -109,11 +136,30 @@ export const MicrosoftPlannerBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: ['create_task', 'read_task', 'read_plan', 'list_buckets', 'create_bucket'], + value: [ + 'create_task', + 'read_task', + 'read_plan', + 'list_buckets', + 'create_bucket', + 'update_plan', + 'delete_plan', + 'get_plan_details', + 'update_plan_details', + ], }, required: { field: 'operation', - value: ['read_plan', 'list_buckets', 'create_bucket', 'create_task'], + value: [ + 'read_plan', + 'list_buckets', + 'create_bucket', + 'create_task', + 'update_plan', + 'delete_plan', + 'get_plan_details', + 'update_plan_details', + ], }, dependsOn: ['credential'], }, @@ -184,6 +230,9 @@ export const MicrosoftPlannerBlock: BlockConfig = { 'update_bucket', 'delete_bucket', 'update_task_details', + 'update_plan', + 'update_plan_details', + 'delete_plan', ], }, dependsOn: ['credential'], @@ -209,13 +258,14 @@ export const MicrosoftPlannerBlock: BlockConfig = { required: { field: 'operation', value: 'create_bucket' }, }, - // Description for task details + // Description for task details (Microsoft Planner tasks store description on the + // task details resource, not the task itself, so this only applies to update_task_details) { id: 'description', title: 'Description', type: 'long-input', placeholder: 'Enter task description', - condition: { field: 'operation', value: ['create_task', 'update_task_details'] }, + condition: { field: 'operation', value: ['update_task_details'] }, }, // Due Date @@ -247,7 +297,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, title: 'Start Date', type: 'short-input', placeholder: 'Enter start date in ISO 8601 format (optional)', - condition: { field: 'operation', value: ['update_task'] }, + mode: 'advanced', + condition: { field: 'operation', value: ['create_task', 'update_task'] }, wandConfig: { enabled: true, prompt: `Generate an ISO 8601 timestamp based on the user's description for Microsoft Planner task start date. @@ -288,7 +339,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, title: 'Priority', type: 'short-input', placeholder: 'Enter priority (0-10, optional)', - condition: { field: 'operation', value: ['update_task'] }, + mode: 'advanced', + condition: { field: 'operation', value: ['create_task', 'update_task'] }, }, // Percent Complete @@ -297,7 +349,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, title: 'Percent Complete', type: 'short-input', placeholder: 'Enter completion percentage (0-100, optional)', - condition: { field: 'operation', value: ['update_task'] }, + mode: 'advanced', + condition: { field: 'operation', value: ['create_task', 'update_task'] }, }, // Checklist for task details @@ -305,7 +358,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, id: 'checklist', title: 'Checklist (JSON)', type: 'long-input', - placeholder: 'Enter checklist as JSON object (optional)', + placeholder: + 'e.g. {"": {"@odata.type": "microsoft.graph.plannerChecklistItem", "title": "Step 1", "isChecked": false}}', + mode: 'advanced', condition: { field: 'operation', value: ['update_task_details'] }, }, @@ -314,7 +369,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, id: 'references', title: 'References (JSON)', type: 'long-input', - placeholder: 'Enter references as JSON object (optional)', + placeholder: + 'e.g. {"https%3A//example%2Ecom": {"@odata.type": "microsoft.graph.plannerExternalReference", "alias": "Docs", "type": "Other"}}', + mode: 'advanced', condition: { field: 'operation', value: ['update_task_details'] }, }, @@ -324,8 +381,72 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, title: 'Preview Type', type: 'short-input', placeholder: 'Enter preview type (automatic, noPreview, checklist, description, reference)', + mode: 'advanced', condition: { field: 'operation', value: ['update_task_details'] }, }, + + // Group ID for create plan + { + id: 'groupId', + title: 'Microsoft 365 Group ID', + type: 'short-input', + placeholder: 'Enter the Microsoft 365 group ID that will own the plan', + required: { field: 'operation', value: 'create_plan' }, + condition: { field: 'operation', value: ['create_plan'] }, + dependsOn: ['credential'], + }, + + // Plan title for create/update plan + { + id: 'planTitle', + title: 'Plan Title', + type: 'short-input', + placeholder: 'Enter the plan title', + required: { field: 'operation', value: ['create_plan', 'update_plan'] }, + condition: { field: 'operation', value: ['create_plan', 'update_plan'] }, + dependsOn: ['credential'], + }, + + // Applied categories for task create/update (color labels) + { + id: 'appliedCategories', + title: 'Categories', + type: 'short-input', + placeholder: 'e.g. category1,category3', + mode: 'advanced', + condition: { field: 'operation', value: ['create_task', 'update_task'] }, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Microsoft Planner category keys based on the user's description. +Valid keys are category1 through category25. +Examples: +- "flag it blocked" -> category1 +- "mark as urgent and needs review" -> category1,category2 + +Return ONLY the comma-separated category keys - no explanations, no extra text.`, + placeholder: 'Describe which category labels to apply (e.g., "mark as blocked")...', + }, + }, + + // Category descriptions for plan details (color label names) + { + id: 'categoryDescriptions', + title: 'Category Descriptions (JSON)', + type: 'long-input', + placeholder: 'e.g. {"category1": "Blocked", "category2": "At Risk"}', + mode: 'advanced', + condition: { field: 'operation', value: ['update_plan_details'] }, + }, + + // Shared with for plan details + { + id: 'sharedWith', + title: 'Shared With (JSON)', + type: 'long-input', + placeholder: 'e.g. {"": true}', + mode: 'advanced', + condition: { field: 'operation', value: ['update_plan_details'] }, + }, ], tools: { access: [ @@ -335,6 +456,11 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, 'microsoft_planner_delete_task', 'microsoft_planner_list_plans', 'microsoft_planner_read_plan', + 'microsoft_planner_create_plan', + 'microsoft_planner_update_plan', + 'microsoft_planner_get_plan_details', + 'microsoft_planner_update_plan_details', + 'microsoft_planner_delete_plan', 'microsoft_planner_list_buckets', 'microsoft_planner_read_bucket', 'microsoft_planner_create_bucket', @@ -358,6 +484,16 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, return 'microsoft_planner_list_plans' case 'read_plan': return 'microsoft_planner_read_plan' + case 'create_plan': + return 'microsoft_planner_create_plan' + case 'update_plan': + return 'microsoft_planner_update_plan' + case 'get_plan_details': + return 'microsoft_planner_get_plan_details' + case 'update_plan_details': + return 'microsoft_planner_update_plan_details' + case 'delete_plan': + return 'microsoft_planner_delete_plan' case 'list_buckets': return 'microsoft_planner_list_buckets' case 'read_bucket': @@ -388,6 +524,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, bucketIdForRead, title, name, + planTitle, description, dueDateTime, startDateTime, @@ -398,6 +535,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, checklist, references, previewType, + appliedCategories, + categoryDescriptions, + sharedWith, ...rest } = params @@ -424,6 +564,58 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, } } + // Create Plan + if (operation === 'create_plan') { + return { + ...baseParams, + groupId: groupId?.trim(), + title: planTitle?.trim(), + } + } + + // Update Plan + if (operation === 'update_plan') { + return { + ...baseParams, + planId: planId?.trim(), + etag: etag?.trim(), + title: planTitle?.trim(), + } + } + + // Get Plan Details + if (operation === 'get_plan_details') { + return { + ...baseParams, + planId: planId?.trim(), + } + } + + // Update Plan Details + if (operation === 'update_plan_details') { + const updatePlanDetailsParams: MicrosoftPlannerBlockParams = { + ...baseParams, + planId: planId?.trim(), + etag: etag?.trim(), + } + if (categoryDescriptions?.trim()) { + updatePlanDetailsParams.categoryDescriptions = categoryDescriptions.trim() + } + if (sharedWith?.trim()) { + updatePlanDetailsParams.sharedWith = sharedWith.trim() + } + return updatePlanDetailsParams + } + + // Delete Plan + if (operation === 'delete_plan') { + return { + ...baseParams, + planId: planId?.trim(), + etag: etag?.trim(), + } + } + // List Buckets if (operation === 'list_buckets') { return { @@ -492,18 +684,27 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, title: title?.trim(), } - if (description?.trim()) { - createParams.description = description.trim() - } if (dueDateTime?.trim()) { createParams.dueDateTime = dueDateTime.trim() } + if (startDateTime?.trim()) { + createParams.startDateTime = startDateTime.trim() + } + if (priority !== undefined) { + createParams.priority = Number(priority) + } + if (percentComplete !== undefined) { + createParams.percentComplete = Number(percentComplete) + } if (assigneeUserId?.trim()) { createParams.assigneeUserId = assigneeUserId.trim() } if (effectiveBucketId) { createParams.bucketId = effectiveBucketId } + if (appliedCategories?.trim()) { + createParams.appliedCategories = appliedCategories.trim() + } return createParams } @@ -537,6 +738,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, if (percentComplete !== undefined) { updateParams.percentComplete = Number(percentComplete) } + if (appliedCategories?.trim()) { + updateParams.appliedCategories = appliedCategories.trim() + } return updateParams } @@ -597,7 +801,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, bucketIdForRead: { type: 'string', description: 'Bucket ID for read operations' }, title: { type: 'string', description: 'Task title' }, name: { type: 'string', description: 'Bucket name' }, - description: { type: 'string', description: 'Task or task details description' }, + planTitle: { type: 'string', description: 'Plan title for create/update plan' }, + description: { type: 'string', description: 'Task details description' }, dueDateTime: { type: 'string', description: 'Due date' }, startDateTime: { type: 'string', description: 'Start date' }, assigneeUserId: { type: 'string', description: 'Assignee user ID' }, @@ -607,6 +812,12 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, checklist: { type: 'string', description: 'Checklist items as JSON' }, references: { type: 'string', description: 'References as JSON' }, previewType: { type: 'string', description: 'Preview type for task details' }, + appliedCategories: { + type: 'string', + description: 'Comma-separated category labels to apply to a task (e.g., category1,category3)', + }, + categoryDescriptions: { type: 'string', description: 'Plan category label names as JSON' }, + sharedWith: { type: 'string', description: 'Plan shared-with user IDs as JSON' }, }, outputs: { message: { @@ -638,6 +849,11 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, type: 'json', description: 'Array of Microsoft Planner plans', }, + planDetails: { + type: 'json', + description: + 'The Microsoft Planner plan details, including categoryDescriptions and sharedWith', + }, bucket: { type: 'json', description: 'The Microsoft Planner bucket object', diff --git a/apps/sim/tools/microsoft_planner/create_plan.ts b/apps/sim/tools/microsoft_planner/create_plan.ts new file mode 100644 index 00000000000..54ccdc56a97 --- /dev/null +++ b/apps/sim/tools/microsoft_planner/create_plan.ts @@ -0,0 +1,111 @@ +import { createLogger } from '@sim/logger' +import type { + MicrosoftPlannerCreatePlanResponse, + MicrosoftPlannerToolParams, +} from '@/tools/microsoft_planner/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('MicrosoftPlannerCreatePlan') + +export const createPlanTool: ToolConfig< + MicrosoftPlannerToolParams, + MicrosoftPlannerCreatePlanResponse +> = { + id: 'microsoft_planner_create_plan', + name: 'Create Microsoft Planner Plan', + description: 'Create a new Microsoft Planner plan owned by a Microsoft 365 group', + version: '1.0', + errorExtractor: 'nested-error-object', + + oauth: { + required: true, + provider: 'microsoft-planner', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Microsoft Planner API', + }, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ID of the Microsoft 365 group that will own the plan (e.g., "ebf3b108-5234-4e22-b93d-656d7dae5874"). The current user must be a member of this group.', + }, + title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The title of the plan', + }, + }, + + request: { + url: () => 'https://graph.microsoft.com/v1.0/planner/plans', + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const groupId = params.groupId?.trim() + if (!groupId) { + throw new Error('Microsoft 365 group ID is required') + } + if (!params.title) { + throw new Error('Plan title is required') + } + + const body = { + container: { + url: `https://graph.microsoft.com/v1.0/groups/${groupId}`, + }, + title: params.title, + } + + logger.info('Creating plan with body:', body) + return body + }, + }, + + transformResponse: async (response: Response) => { + const plan = await response.json() + logger.info('Created plan:', plan) + + const result: MicrosoftPlannerCreatePlanResponse = { + success: true, + output: { + plan, + metadata: { + planId: plan.id, + groupId: plan.container?.containerId, + }, + }, + } + + return result + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the plan was created successfully' }, + plan: { type: 'object', description: 'The created plan object with all properties' }, + metadata: { + type: 'object', + description: 'Metadata including planId and groupId', + properties: { + planId: { type: 'string', description: 'Created plan ID' }, + groupId: { type: 'string', description: 'Owning Microsoft 365 group ID' }, + }, + }, + }, +} diff --git a/apps/sim/tools/microsoft_planner/create_task.ts b/apps/sim/tools/microsoft_planner/create_task.ts index 91e0728de26..9d9bf0c94ab 100644 --- a/apps/sim/tools/microsoft_planner/create_task.ts +++ b/apps/sim/tools/microsoft_planner/create_task.ts @@ -42,12 +42,6 @@ export const createTaskTool: ToolConfig< visibility: 'user-or-llm', description: 'The title of the task (e.g., "Review quarterly report")', }, - description: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'The description of the task', - }, dueDateTime: { type: 'string', required: false, @@ -55,6 +49,25 @@ export const createTaskTool: ToolConfig< description: 'The due date and time for the task in ISO 8601 format (e.g., "2025-03-15T17:00:00Z")', }, + startDateTime: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'The start date and time for the task in ISO 8601 format (e.g., "2025-03-10T09:00:00Z")', + }, + priority: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'The priority of the task (0-10, where 0 is urgent and 10 is low)', + }, + percentComplete: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'The percentage of task completion (0-100)', + }, assigneeUserId: { type: 'string', required: false, @@ -68,6 +81,13 @@ export const createTaskTool: ToolConfig< visibility: 'user-or-llm', description: 'The bucket ID to place the task in (e.g., "hsOf2dhOJkC6Fey9VjDg1JgAC9Rq")', }, + appliedCategories: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated category labels to apply to the task, e.g. "category1,category3" (up to category1-category25, plan-defined color labels)', + }, }, request: { @@ -108,6 +128,22 @@ export const createTaskTool: ToolConfig< body.dueDateTime = params.dueDateTime } + if ( + params.startDateTime !== undefined && + params.startDateTime !== null && + params.startDateTime !== '' + ) { + body.startDateTime = params.startDateTime + } + + if (params.priority !== undefined && params.priority !== null) { + body.priority = Number(params.priority) + } + + if (params.percentComplete !== undefined && params.percentComplete !== null) { + body.percentComplete = Number(params.percentComplete) + } + if ( params.assigneeUserId !== undefined && params.assigneeUserId !== null && @@ -115,12 +151,25 @@ export const createTaskTool: ToolConfig< ) { body.assignments = { [params.assigneeUserId]: { - '@odata.type': 'microsoft.graph.plannerAssignment', + '@odata.type': '#microsoft.graph.plannerAssignment', orderHint: ' !', }, } } + if (params.appliedCategories?.trim()) { + const categories = params.appliedCategories + .split(',') + .map((category) => category.trim()) + .filter(Boolean) + + if (categories.length > 0) { + body.appliedCategories = Object.fromEntries( + categories.map((category) => [category, true]) + ) + } + } + logger.info('Creating task with body:', body) return body }, diff --git a/apps/sim/tools/microsoft_planner/delete_bucket.ts b/apps/sim/tools/microsoft_planner/delete_bucket.ts index 2539026aef1..f9e1ffda19e 100644 --- a/apps/sim/tools/microsoft_planner/delete_bucket.ts +++ b/apps/sim/tools/microsoft_planner/delete_bucket.ts @@ -44,10 +44,11 @@ export const deleteBucketTool: ToolConfig< request: { url: (params) => { - if (!params.bucketId) { + const bucketId = params.bucketId?.trim() + if (!bucketId) { throw new Error('Bucket ID is required') } - return `https://graph.microsoft.com/v1.0/planner/buckets/${params.bucketId}` + return `https://graph.microsoft.com/v1.0/planner/buckets/${bucketId}` }, method: 'DELETE', headers: (params) => { diff --git a/apps/sim/tools/microsoft_planner/delete_plan.ts b/apps/sim/tools/microsoft_planner/delete_plan.ts new file mode 100644 index 00000000000..510286a20d5 --- /dev/null +++ b/apps/sim/tools/microsoft_planner/delete_plan.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@sim/logger' +import type { + MicrosoftPlannerDeletePlanResponse, + MicrosoftPlannerToolParams, +} from '@/tools/microsoft_planner/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('MicrosoftPlannerDeletePlan') + +export const deletePlanTool: ToolConfig< + MicrosoftPlannerToolParams, + MicrosoftPlannerDeletePlanResponse +> = { + id: 'microsoft_planner_delete_plan', + name: 'Delete Microsoft Planner Plan', + description: 'Delete a Microsoft Planner plan', + version: '1.0', + + oauth: { + required: true, + provider: 'microsoft-planner', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Microsoft Planner API', + }, + planId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the plan to delete (e.g., "xqQg5FS2LkCe54tAMV_v2ZgADW2J")', + }, + etag: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The ETag value from the plan to delete (If-Match header)', + }, + }, + + request: { + url: (params) => { + const planId = params.planId?.trim() + if (!planId) { + throw new Error('Plan ID is required') + } + return `https://graph.microsoft.com/v1.0/planner/plans/${planId}` + }, + method: 'DELETE', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + if (!params.etag) { + throw new Error('ETag is required for delete operations') + } + + let cleanedEtag = params.etag.trim() + + while (cleanedEtag.startsWith('"') && cleanedEtag.endsWith('"')) { + cleanedEtag = cleanedEtag.slice(1, -1) + logger.info('Removed surrounding quotes:', cleanedEtag) + } + + if (cleanedEtag.includes('\\"')) { + cleanedEtag = cleanedEtag.replace(/\\"/g, '"') + logger.info('Cleaned escaped quotes from etag') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'If-Match': cleanedEtag, + } + }, + }, + + transformResponse: async (response: Response) => { + logger.info('Plan deleted successfully') + + const result: MicrosoftPlannerDeletePlanResponse = { + success: true, + output: { + deleted: true, + metadata: {}, + }, + } + + return result + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the plan was deleted successfully' }, + deleted: { type: 'boolean', description: 'Confirmation of deletion' }, + metadata: { type: 'object', description: 'Additional metadata' }, + }, +} diff --git a/apps/sim/tools/microsoft_planner/delete_task.ts b/apps/sim/tools/microsoft_planner/delete_task.ts index 961d6f4ec39..b4af0dc3d52 100644 --- a/apps/sim/tools/microsoft_planner/delete_task.ts +++ b/apps/sim/tools/microsoft_planner/delete_task.ts @@ -44,10 +44,11 @@ export const deleteTaskTool: ToolConfig< request: { url: (params) => { - if (!params.taskId) { + const taskId = params.taskId?.trim() + if (!taskId) { throw new Error('Task ID is required') } - return `https://graph.microsoft.com/v1.0/planner/tasks/${params.taskId}` + return `https://graph.microsoft.com/v1.0/planner/tasks/${taskId}` }, method: 'DELETE', headers: (params) => { diff --git a/apps/sim/tools/microsoft_planner/get_plan_details.ts b/apps/sim/tools/microsoft_planner/get_plan_details.ts new file mode 100644 index 00000000000..ad64120676c --- /dev/null +++ b/apps/sim/tools/microsoft_planner/get_plan_details.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@sim/logger' +import type { + MicrosoftPlannerGetPlanDetailsResponse, + MicrosoftPlannerToolParams, +} from '@/tools/microsoft_planner/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('MicrosoftPlannerGetPlanDetails') + +export const getPlanDetailsTool: ToolConfig< + MicrosoftPlannerToolParams, + MicrosoftPlannerGetPlanDetailsResponse +> = { + id: 'microsoft_planner_get_plan_details', + name: 'Get Microsoft Planner Plan Details', + description: 'Get detailed information about a plan including category descriptions and sharing', + version: '1.0', + + oauth: { + required: true, + provider: 'microsoft-planner', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Microsoft Planner API', + }, + planId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the plan (e.g., "xqQg5FS2LkCe54tAMV_v2ZgADW2J")', + }, + }, + + request: { + url: (params) => { + const planId = params.planId?.trim() + if (!planId) { + throw new Error('Plan ID is required') + } + return `https://graph.microsoft.com/v1.0/planner/plans/${planId}/details` + }, + method: 'GET', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + } + }, + }, + + transformResponse: async (response: Response) => { + const planDetails = await response.json() + logger.info('Plan details retrieved:', planDetails) + + const etag = planDetails['@odata.etag'] || '' + + const result: MicrosoftPlannerGetPlanDetailsResponse = { + success: true, + output: { + planDetails, + etag, + metadata: { + planId: planDetails.id, + }, + }, + } + + return result + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the plan details were retrieved successfully', + }, + planDetails: { + type: 'object', + description: 'The plan details including categoryDescriptions and sharedWith', + }, + etag: { + type: 'string', + description: 'The ETag value for this plan details resource', + }, + metadata: { + type: 'object', + description: 'Metadata including planId', + properties: { + planId: { type: 'string', description: 'Plan ID' }, + }, + }, + }, +} diff --git a/apps/sim/tools/microsoft_planner/get_task_details.ts b/apps/sim/tools/microsoft_planner/get_task_details.ts index cd75f4d758f..961564f6246 100644 --- a/apps/sim/tools/microsoft_planner/get_task_details.ts +++ b/apps/sim/tools/microsoft_planner/get_task_details.ts @@ -38,10 +38,11 @@ export const getTaskDetailsTool: ToolConfig< request: { url: (params) => { - if (!params.taskId) { + const taskId = params.taskId?.trim() + if (!taskId) { throw new Error('Task ID is required') } - return `https://graph.microsoft.com/v1.0/planner/tasks/${params.taskId}/details` + return `https://graph.microsoft.com/v1.0/planner/tasks/${taskId}/details` }, method: 'GET', headers: (params) => { diff --git a/apps/sim/tools/microsoft_planner/index.ts b/apps/sim/tools/microsoft_planner/index.ts index 6450e280169..11800fe376b 100644 --- a/apps/sim/tools/microsoft_planner/index.ts +++ b/apps/sim/tools/microsoft_planner/index.ts @@ -1,7 +1,10 @@ import { createBucketTool } from '@/tools/microsoft_planner/create_bucket' +import { createPlanTool } from '@/tools/microsoft_planner/create_plan' import { createTaskTool } from '@/tools/microsoft_planner/create_task' import { deleteBucketTool } from '@/tools/microsoft_planner/delete_bucket' +import { deletePlanTool } from '@/tools/microsoft_planner/delete_plan' import { deleteTaskTool } from '@/tools/microsoft_planner/delete_task' +import { getPlanDetailsTool } from '@/tools/microsoft_planner/get_plan_details' import { getTaskDetailsTool } from '@/tools/microsoft_planner/get_task_details' import { listBucketsTool } from '@/tools/microsoft_planner/list_buckets' import { listPlansTool } from '@/tools/microsoft_planner/list_plans' @@ -9,6 +12,8 @@ import { readBucketTool } from '@/tools/microsoft_planner/read_bucket' import { readPlanTool } from '@/tools/microsoft_planner/read_plan' import { readTaskTool } from '@/tools/microsoft_planner/read_task' import { updateBucketTool } from '@/tools/microsoft_planner/update_bucket' +import { updatePlanTool } from '@/tools/microsoft_planner/update_plan' +import { updatePlanDetailsTool } from '@/tools/microsoft_planner/update_plan_details' import { updateTaskTool } from '@/tools/microsoft_planner/update_task' import { updateTaskDetailsTool } from '@/tools/microsoft_planner/update_task_details' @@ -18,6 +23,11 @@ export const microsoftPlannerUpdateTaskTool = updateTaskTool export const microsoftPlannerDeleteTaskTool = deleteTaskTool export const microsoftPlannerListPlansTool = listPlansTool export const microsoftPlannerReadPlanTool = readPlanTool +export const microsoftPlannerCreatePlanTool = createPlanTool +export const microsoftPlannerUpdatePlanTool = updatePlanTool +export const microsoftPlannerGetPlanDetailsTool = getPlanDetailsTool +export const microsoftPlannerUpdatePlanDetailsTool = updatePlanDetailsTool +export const microsoftPlannerDeletePlanTool = deletePlanTool export const microsoftPlannerListBucketsTool = listBucketsTool export const microsoftPlannerReadBucketTool = readBucketTool export const microsoftPlannerCreateBucketTool = createBucketTool diff --git a/apps/sim/tools/microsoft_planner/list_buckets.ts b/apps/sim/tools/microsoft_planner/list_buckets.ts index 23f8186d9bd..a610e1a5d2a 100644 --- a/apps/sim/tools/microsoft_planner/list_buckets.ts +++ b/apps/sim/tools/microsoft_planner/list_buckets.ts @@ -38,10 +38,11 @@ export const listBucketsTool: ToolConfig< request: { url: (params) => { - if (!params.planId) { + const planId = params.planId?.trim() + if (!planId) { throw new Error('Plan ID is required') } - return `https://graph.microsoft.com/v1.0/planner/plans/${params.planId}/buckets` + return `https://graph.microsoft.com/v1.0/planner/plans/${planId}/buckets` }, method: 'GET', headers: (params) => { diff --git a/apps/sim/tools/microsoft_planner/read_bucket.ts b/apps/sim/tools/microsoft_planner/read_bucket.ts index 9eac75cd33e..eb5d94be329 100644 --- a/apps/sim/tools/microsoft_planner/read_bucket.ts +++ b/apps/sim/tools/microsoft_planner/read_bucket.ts @@ -38,10 +38,11 @@ export const readBucketTool: ToolConfig< request: { url: (params) => { - if (!params.bucketId) { + const bucketId = params.bucketId?.trim() + if (!bucketId) { throw new Error('Bucket ID is required') } - return `https://graph.microsoft.com/v1.0/planner/buckets/${params.bucketId}` + return `https://graph.microsoft.com/v1.0/planner/buckets/${bucketId}` }, method: 'GET', headers: (params) => { diff --git a/apps/sim/tools/microsoft_planner/read_plan.ts b/apps/sim/tools/microsoft_planner/read_plan.ts index 7c5d2ae3d54..68be4dd8c81 100644 --- a/apps/sim/tools/microsoft_planner/read_plan.ts +++ b/apps/sim/tools/microsoft_planner/read_plan.ts @@ -38,10 +38,11 @@ export const readPlanTool: ToolConfig< request: { url: (params) => { - if (!params.planId) { + const planId = params.planId?.trim() + if (!planId) { throw new Error('Plan ID is required') } - return `https://graph.microsoft.com/v1.0/planner/plans/${params.planId}` + return `https://graph.microsoft.com/v1.0/planner/plans/${planId}` }, method: 'GET', headers: (params) => { diff --git a/apps/sim/tools/microsoft_planner/types.ts b/apps/sim/tools/microsoft_planner/types.ts index 344e95b616c..6b30db41eea 100644 --- a/apps/sim/tools/microsoft_planner/types.ts +++ b/apps/sim/tools/microsoft_planner/types.ts @@ -41,6 +41,10 @@ interface PlannerContainer { url?: string } +interface PlannerAppliedCategories { + [category: string]: boolean +} + export interface PlannerTask { id?: string planId: string @@ -61,6 +65,7 @@ export interface PlannerTask { conversationThreadId?: string priority?: number assignments?: Record + appliedCategories?: PlannerAppliedCategories bucketId?: string details?: { description?: string @@ -69,7 +74,7 @@ export interface PlannerTask { } } -interface PlannerBucket { +export interface PlannerBucket { id: string name: string planId: string @@ -77,7 +82,7 @@ interface PlannerBucket { '@odata.etag'?: string } -interface PlannerPlan { +export interface PlannerPlan { id: string title: string owner?: string @@ -86,7 +91,14 @@ interface PlannerPlan { '@odata.etag'?: string } -interface PlannerTaskDetails { +export interface PlannerPlanDetails { + id: string + categoryDescriptions?: Record + sharedWith?: Record + '@odata.etag'?: string +} + +export interface PlannerTaskDetails { id: string description?: string previewType?: string @@ -153,6 +165,28 @@ export interface MicrosoftPlannerReadPlanResponse extends ToolResponse { } } +export interface MicrosoftPlannerCreatePlanResponse extends ToolResponse { + output: { + plan: PlannerPlan + metadata: MicrosoftPlannerMetadata + } +} + +export interface MicrosoftPlannerDeletePlanResponse extends ToolResponse { + output: { + deleted: boolean + metadata: MicrosoftPlannerMetadata + } +} + +export interface MicrosoftPlannerGetPlanDetailsResponse extends ToolResponse { + output: { + planDetails: PlannerPlanDetails + etag: string + metadata: MicrosoftPlannerMetadata + } +} + export interface MicrosoftPlannerListBucketsResponse extends ToolResponse { output: { buckets: PlannerBucket[] @@ -203,6 +237,20 @@ export interface MicrosoftPlannerUpdateTaskDetailsResponse extends ToolResponse } } +export interface MicrosoftPlannerUpdatePlanResponse extends ToolResponse { + output: { + plan: PlannerPlan + metadata: MicrosoftPlannerMetadata + } +} + +export interface MicrosoftPlannerUpdatePlanDetailsResponse extends ToolResponse { + output: { + planDetails: PlannerPlanDetails + metadata: MicrosoftPlannerMetadata + } +} + export interface MicrosoftPlannerToolParams { accessToken: string planId?: string @@ -221,6 +269,9 @@ export interface MicrosoftPlannerToolParams { checklist?: Record references?: Record previewType?: string + appliedCategories?: string + categoryDescriptions?: Record + sharedWith?: Record } export type MicrosoftPlannerResponse = @@ -230,6 +281,9 @@ export type MicrosoftPlannerResponse = | MicrosoftPlannerDeleteTaskResponse | MicrosoftPlannerListPlansResponse | MicrosoftPlannerReadPlanResponse + | MicrosoftPlannerCreatePlanResponse + | MicrosoftPlannerDeletePlanResponse + | MicrosoftPlannerGetPlanDetailsResponse | MicrosoftPlannerListBucketsResponse | MicrosoftPlannerReadBucketResponse | MicrosoftPlannerCreateBucketResponse @@ -237,3 +291,5 @@ export type MicrosoftPlannerResponse = | MicrosoftPlannerDeleteBucketResponse | MicrosoftPlannerGetTaskDetailsResponse | MicrosoftPlannerUpdateTaskDetailsResponse + | MicrosoftPlannerUpdatePlanResponse + | MicrosoftPlannerUpdatePlanDetailsResponse diff --git a/apps/sim/tools/microsoft_planner/update_bucket.ts b/apps/sim/tools/microsoft_planner/update_bucket.ts index 08e41b6fab9..db71b8dd966 100644 --- a/apps/sim/tools/microsoft_planner/update_bucket.ts +++ b/apps/sim/tools/microsoft_planner/update_bucket.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import type { MicrosoftPlannerToolParams, MicrosoftPlannerUpdateBucketResponse, + PlannerBucket, } from '@/tools/microsoft_planner/types' import type { ToolConfig } from '@/tools/types' @@ -51,10 +52,11 @@ export const updateBucketTool: ToolConfig< request: { url: (params) => { - if (!params.bucketId) { + const bucketId = params.bucketId?.trim() + if (!bucketId) { throw new Error('Bucket ID is required') } - return `https://graph.microsoft.com/v1.0/planner/buckets/${params.bucketId}` + return `https://graph.microsoft.com/v1.0/planner/buckets/${bucketId}` }, method: 'PATCH', headers: (params) => { @@ -80,6 +82,7 @@ export const updateBucketTool: ToolConfig< return { Authorization: `Bearer ${params.accessToken}`, 'Content-Type': 'application/json', + Prefer: 'return=representation', 'If-Match': cleanedEtag, } }, @@ -99,8 +102,24 @@ export const updateBucketTool: ToolConfig< }, }, - transformResponse: async (response: Response) => { - const bucket = await response.json() + transformResponse: async (response: Response, params?: MicrosoftPlannerToolParams) => { + // Prefer: return=representation requests a body, but the service may still return + // 204 No Content for some tenants/requests + const text = await response.text() + if (!text || text.trim() === '') { + logger.info('Update successful but no response body returned (204 No Content)') + return { + success: true, + output: { + bucket: {} as PlannerBucket, + metadata: { + bucketId: params?.bucketId?.trim(), + }, + }, + } + } + + const bucket = JSON.parse(text) logger.info('Updated bucket:', bucket) const result: MicrosoftPlannerUpdateBucketResponse = { diff --git a/apps/sim/tools/microsoft_planner/update_plan.ts b/apps/sim/tools/microsoft_planner/update_plan.ts new file mode 100644 index 00000000000..462c15a2f12 --- /dev/null +++ b/apps/sim/tools/microsoft_planner/update_plan.ts @@ -0,0 +1,143 @@ +import { createLogger } from '@sim/logger' +import type { + MicrosoftPlannerToolParams, + MicrosoftPlannerUpdatePlanResponse, + PlannerPlan, +} from '@/tools/microsoft_planner/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('MicrosoftPlannerUpdatePlan') + +export const updatePlanTool: ToolConfig< + MicrosoftPlannerToolParams, + MicrosoftPlannerUpdatePlanResponse +> = { + id: 'microsoft_planner_update_plan', + name: 'Update Microsoft Planner Plan', + description: 'Rename a Microsoft Planner plan', + version: '1.0', + errorExtractor: 'nested-error-object', + + oauth: { + required: true, + provider: 'microsoft-planner', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Microsoft Planner API', + }, + planId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the plan to update (e.g., "xqQg5FS2LkCe54tAMV_v2ZgADW2J")', + }, + etag: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The ETag value from the plan to update (If-Match header)', + }, + title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The new title of the plan', + }, + }, + + request: { + url: (params) => { + const planId = params.planId?.trim() + if (!planId) { + throw new Error('Plan ID is required') + } + return `https://graph.microsoft.com/v1.0/planner/plans/${planId}` + }, + method: 'PATCH', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + if (!params.etag) { + throw new Error('ETag is required for update operations') + } + + let cleanedEtag = params.etag.trim() + + while (cleanedEtag.startsWith('"') && cleanedEtag.endsWith('"')) { + cleanedEtag = cleanedEtag.slice(1, -1) + } + + if (cleanedEtag.includes('\\"')) { + cleanedEtag = cleanedEtag.replace(/\\"/g, '"') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + 'If-Match': cleanedEtag, + } + }, + body: (params) => { + if (!params.title?.trim()) { + throw new Error('Plan title is required') + } + + const body = { title: params.title.trim() } + + logger.info('Updating plan with body:', body) + return body + }, + }, + + transformResponse: async (response: Response, params?: MicrosoftPlannerToolParams) => { + // Prefer: return=representation requests a body, but the service may still return + // 204 No Content for some tenants/requests + const text = await response.text() + if (!text || text.trim() === '') { + logger.info('Update successful but no response body returned (204 No Content)') + return { + success: true, + output: { + plan: {} as PlannerPlan, + metadata: { + planId: params?.planId?.trim(), + }, + }, + } + } + + const plan = JSON.parse(text) + logger.info('Updated plan:', plan) + + const result: MicrosoftPlannerUpdatePlanResponse = { + success: true, + output: { + plan, + metadata: { + planId: plan.id, + }, + }, + } + + return result + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the plan was updated successfully' }, + plan: { type: 'object', description: 'The updated plan object with all properties' }, + metadata: { + type: 'object', + description: 'Metadata including planId', + properties: { + planId: { type: 'string', description: 'Updated plan ID' }, + }, + }, + }, +} diff --git a/apps/sim/tools/microsoft_planner/update_plan_details.ts b/apps/sim/tools/microsoft_planner/update_plan_details.ts new file mode 100644 index 00000000000..288f3bca690 --- /dev/null +++ b/apps/sim/tools/microsoft_planner/update_plan_details.ts @@ -0,0 +1,180 @@ +import { createLogger } from '@sim/logger' +import type { + MicrosoftPlannerToolParams, + MicrosoftPlannerUpdatePlanDetailsResponse, + PlannerPlanDetails, +} from '@/tools/microsoft_planner/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('MicrosoftPlannerUpdatePlanDetails') + +export const updatePlanDetailsTool: ToolConfig< + MicrosoftPlannerToolParams, + MicrosoftPlannerUpdatePlanDetailsResponse +> = { + id: 'microsoft_planner_update_plan_details', + name: 'Update Microsoft Planner Plan Details', + description: + "Update a plan's category (color label) descriptions and shared-with user list in Microsoft Planner", + version: '1.0', + errorExtractor: 'nested-error-object', + + oauth: { + required: true, + provider: 'microsoft-planner', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Microsoft Planner API', + }, + planId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the plan (e.g., "xqQg5FS2LkCe54tAMV_v2ZgADW2J")', + }, + etag: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The ETag value from the plan details to update (If-Match header)', + }, + categoryDescriptions: { + type: 'object', + required: false, + visibility: 'user-only', + description: + 'Category label names as a JSON object, e.g. {"category1": "Blocked", "category2": "At Risk"}. Set a value to null to clear a label.', + }, + sharedWith: { + type: 'object', + required: false, + visibility: 'user-only', + description: + 'User IDs to share the plan with as a JSON object, e.g. {"": true}. Set a value to false to unshare.', + }, + }, + + request: { + url: (params) => { + const planId = params.planId?.trim() + if (!planId) { + throw new Error('Plan ID is required') + } + return `https://graph.microsoft.com/v1.0/planner/plans/${planId}/details` + }, + method: 'PATCH', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + if (!params.etag) { + throw new Error('ETag is required for update operations') + } + + let cleanedEtag = params.etag.trim() + + while (cleanedEtag.startsWith('"') && cleanedEtag.endsWith('"')) { + cleanedEtag = cleanedEtag.slice(1, -1) + } + + if (cleanedEtag.includes('\\"')) { + cleanedEtag = cleanedEtag.replace(/\\"/g, '"') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + 'If-Match': cleanedEtag, + } + }, + body: (params) => { + const body: Record = {} + + if (params.categoryDescriptions) { + try { + body.categoryDescriptions = + typeof params.categoryDescriptions === 'string' + ? JSON.parse(params.categoryDescriptions) + : params.categoryDescriptions + } catch { + throw new Error('categoryDescriptions must be valid JSON') + } + } + + if (params.sharedWith) { + try { + body.sharedWith = + typeof params.sharedWith === 'string' + ? JSON.parse(params.sharedWith) + : params.sharedWith + } catch { + throw new Error('sharedWith must be valid JSON') + } + } + + if (Object.keys(body).length === 0) { + throw new Error('At least one field must be provided to update') + } + + logger.info('Updating plan details with body:', body) + return body + }, + }, + + transformResponse: async (response: Response, params?: MicrosoftPlannerToolParams) => { + // Prefer: return=representation requests a body, but the service may still return + // 204 No Content for some tenants/requests + const text = await response.text() + if (!text || text.trim() === '') { + logger.info('Update successful but no response body returned (204 No Content)') + return { + success: true, + output: { + planDetails: {} as PlannerPlanDetails, + metadata: { + planId: params?.planId?.trim(), + }, + }, + } + } + + const planDetails = JSON.parse(text) + logger.info('Updated plan details:', planDetails) + + const result: MicrosoftPlannerUpdatePlanDetailsResponse = { + success: true, + output: { + planDetails, + metadata: { + planId: planDetails.id, + }, + }, + } + + return result + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the plan details were updated successfully', + }, + planDetails: { + type: 'object', + description: 'The updated plan details object with categoryDescriptions and sharedWith', + }, + metadata: { + type: 'object', + description: 'Metadata including planId', + properties: { + planId: { type: 'string', description: 'Plan ID' }, + }, + }, + }, +} diff --git a/apps/sim/tools/microsoft_planner/update_task.ts b/apps/sim/tools/microsoft_planner/update_task.ts index e088b3aa2ef..cc60078317e 100644 --- a/apps/sim/tools/microsoft_planner/update_task.ts +++ b/apps/sim/tools/microsoft_planner/update_task.ts @@ -86,14 +86,22 @@ export const updateTaskTool: ToolConfig< description: 'The user ID to assign the task to (e.g., "e82f74c3-4d8a-4b5c-9f1e-2a6b8c9d0e3f")', }, + appliedCategories: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated category labels to apply to the task, e.g. "category1,category3" (up to category1-category25, plan-defined color labels)', + }, }, request: { url: (params) => { - if (!params.taskId) { + const taskId = params.taskId?.trim() + if (!taskId) { throw new Error('Task ID is required') } - return `https://graph.microsoft.com/v1.0/planner/tasks/${params.taskId}` + return `https://graph.microsoft.com/v1.0/planner/tasks/${taskId}` }, method: 'PATCH', headers: (params) => { @@ -123,6 +131,7 @@ export const updateTaskTool: ToolConfig< return { Authorization: `Bearer ${params.accessToken}`, 'Content-Type': 'application/json', + Prefer: 'return=representation', 'If-Match': cleanedEtag, } }, @@ -168,12 +177,25 @@ export const updateTaskTool: ToolConfig< ) { body.assignments = { [params.assigneeUserId]: { - '@odata.type': 'microsoft.graph.plannerAssignment', + '@odata.type': '#microsoft.graph.plannerAssignment', orderHint: ' !', }, } } + if (params.appliedCategories?.trim()) { + const categories = params.appliedCategories + .split(',') + .map((category) => category.trim()) + .filter(Boolean) + + if (categories.length > 0) { + body.appliedCategories = Object.fromEntries( + categories.map((category) => [category, true]) + ) + } + } + if (Object.keys(body).length === 0) { throw new Error('At least one field must be provided to update') } @@ -184,7 +206,8 @@ export const updateTaskTool: ToolConfig< }, transformResponse: async (response: Response, params?: MicrosoftPlannerToolParams) => { - // Check if response has content before parsing + // Check if response has content before parsing (Prefer: return=representation requests a + // body, but the service may still return 204 No Content for some tenants/requests) const text = await response.text() if (!text || text.trim() === '') { logger.info('Update successful but no response body returned (204 No Content)') @@ -193,10 +216,10 @@ export const updateTaskTool: ToolConfig< output: { message: 'Task updated successfully', task: {} as PlannerTask, - taskId: params?.taskId || '', + taskId: params?.taskId?.trim() || '', etag: params?.etag || '', metadata: { - taskId: params?.taskId, + taskId: params?.taskId?.trim(), }, }, } diff --git a/apps/sim/tools/microsoft_planner/update_task_details.ts b/apps/sim/tools/microsoft_planner/update_task_details.ts index 62ad7f45043..7c170427c21 100644 --- a/apps/sim/tools/microsoft_planner/update_task_details.ts +++ b/apps/sim/tools/microsoft_planner/update_task_details.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import type { MicrosoftPlannerToolParams, MicrosoftPlannerUpdateTaskDetailsResponse, + PlannerTaskDetails, } from '@/tools/microsoft_planner/types' import type { ToolConfig } from '@/tools/types' @@ -70,10 +71,11 @@ export const updateTaskDetailsTool: ToolConfig< request: { url: (params) => { - if (!params.taskId) { + const taskId = params.taskId?.trim() + if (!taskId) { throw new Error('Task ID is required') } - return `https://graph.microsoft.com/v1.0/planner/tasks/${params.taskId}/details` + return `https://graph.microsoft.com/v1.0/planner/tasks/${taskId}/details` }, method: 'PATCH', headers: (params) => { @@ -127,11 +129,23 @@ export const updateTaskDetailsTool: ToolConfig< } if (params.checklist) { - body.checklist = params.checklist + try { + body.checklist = + typeof params.checklist === 'string' ? JSON.parse(params.checklist) : params.checklist + } catch { + throw new Error('Checklist must be valid JSON') + } } if (params.references) { - body.references = params.references + try { + body.references = + typeof params.references === 'string' + ? JSON.parse(params.references) + : params.references + } catch { + throw new Error('References must be valid JSON') + } } if (params.previewType) { @@ -147,8 +161,24 @@ export const updateTaskDetailsTool: ToolConfig< }, }, - transformResponse: async (response: Response) => { - const taskDetails = await response.json() + transformResponse: async (response: Response, params?: MicrosoftPlannerToolParams) => { + // Prefer: return=representation requests a body, but the service may still return + // 204 No Content for some tenants/requests + const text = await response.text() + if (!text || text.trim() === '') { + logger.info('Update successful but no response body returned (204 No Content)') + return { + success: true, + output: { + taskDetails: {} as PlannerTaskDetails, + metadata: { + taskId: params?.taskId?.trim(), + }, + }, + } + } + + const taskDetails = JSON.parse(text) logger.info('Updated task details:', taskDetails) const result: MicrosoftPlannerUpdateTaskDetailsResponse = { diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 02c6f7c8e7b..03c76da6e37 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2321,9 +2321,12 @@ import { } from '@/tools/microsoft_excel' import { microsoftPlannerCreateBucketTool, + microsoftPlannerCreatePlanTool, microsoftPlannerCreateTaskTool, microsoftPlannerDeleteBucketTool, + microsoftPlannerDeletePlanTool, microsoftPlannerDeleteTaskTool, + microsoftPlannerGetPlanDetailsTool, microsoftPlannerGetTaskDetailsTool, microsoftPlannerListBucketsTool, microsoftPlannerListPlansTool, @@ -2331,6 +2334,8 @@ import { microsoftPlannerReadPlanTool, microsoftPlannerReadTaskTool, microsoftPlannerUpdateBucketTool, + microsoftPlannerUpdatePlanDetailsTool, + microsoftPlannerUpdatePlanTool, microsoftPlannerUpdateTaskDetailsTool, microsoftPlannerUpdateTaskTool, } from '@/tools/microsoft_planner' @@ -7609,6 +7614,11 @@ export const tools: Record = { microsoft_planner_delete_task: microsoftPlannerDeleteTaskTool, microsoft_planner_list_plans: microsoftPlannerListPlansTool, microsoft_planner_read_plan: microsoftPlannerReadPlanTool, + microsoft_planner_create_plan: microsoftPlannerCreatePlanTool, + microsoft_planner_update_plan: microsoftPlannerUpdatePlanTool, + microsoft_planner_get_plan_details: microsoftPlannerGetPlanDetailsTool, + microsoft_planner_update_plan_details: microsoftPlannerUpdatePlanDetailsTool, + microsoft_planner_delete_plan: microsoftPlannerDeletePlanTool, microsoft_planner_list_buckets: microsoftPlannerListBucketsTool, microsoft_planner_read_bucket: microsoftPlannerReadBucketTool, microsoft_planner_create_bucket: microsoftPlannerCreateBucketTool, From 1a24a6292f01371e43ba96e013098a49eb32a13a Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 7 Jul 2026 11:35:26 -0700 Subject: [PATCH 2/4] fix(microsoft-planner): use Graph-specific error extractor consistently 7 tools were pinned to the generic 'nested-error-object' extractor instead of MICROSOFT_GRAPH_ERRORS, losing Graph's inner-error detail (e.g. ETag mismatch specifics) that sibling Microsoft integrations (Excel, OneDrive, SharePoint) already surface correctly. --- apps/sim/tools/microsoft_planner/create_plan.ts | 3 ++- apps/sim/tools/microsoft_planner/read_task.ts | 3 ++- apps/sim/tools/microsoft_planner/update_bucket.ts | 3 ++- apps/sim/tools/microsoft_planner/update_plan.ts | 3 ++- apps/sim/tools/microsoft_planner/update_plan_details.ts | 3 ++- apps/sim/tools/microsoft_planner/update_task.ts | 3 ++- apps/sim/tools/microsoft_planner/update_task_details.ts | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/sim/tools/microsoft_planner/create_plan.ts b/apps/sim/tools/microsoft_planner/create_plan.ts index 54ccdc56a97..da028e304b4 100644 --- a/apps/sim/tools/microsoft_planner/create_plan.ts +++ b/apps/sim/tools/microsoft_planner/create_plan.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { ErrorExtractorId } from '@/tools/error-extractors' import type { MicrosoftPlannerCreatePlanResponse, MicrosoftPlannerToolParams, @@ -15,7 +16,7 @@ export const createPlanTool: ToolConfig< name: 'Create Microsoft Planner Plan', description: 'Create a new Microsoft Planner plan owned by a Microsoft 365 group', version: '1.0', - errorExtractor: 'nested-error-object', + errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS, oauth: { required: true, diff --git a/apps/sim/tools/microsoft_planner/read_task.ts b/apps/sim/tools/microsoft_planner/read_task.ts index e44d5e175c0..16b47d23a0b 100644 --- a/apps/sim/tools/microsoft_planner/read_task.ts +++ b/apps/sim/tools/microsoft_planner/read_task.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { ErrorExtractorId } from '@/tools/error-extractors' import type { MicrosoftPlannerReadResponse, MicrosoftPlannerToolParams, @@ -13,7 +14,7 @@ export const readTaskTool: ToolConfig Date: Tue, 7 Jul 2026 11:45:00 -0700 Subject: [PATCH 3/4] fix(microsoft-planner): address Cursor/Greptile round-1 review findings - fix empty priority/percentComplete string coercing to 0 (Number('')) at the block layer, which Planner treats as urgent/0% instead of leaving unset - support removing applied categories on update via a "-category3" prefix, since Graph only clears a label when its key is explicitly set to false - add missing MICROSOFT_GRAPH_ERRORS extractor to delete_plan/get_plan_details --- apps/sim/blocks/blocks/microsoft_planner.ts | 16 +++++++++------- apps/sim/tools/microsoft_planner/create_task.ts | 4 +++- apps/sim/tools/microsoft_planner/delete_plan.ts | 2 ++ .../tools/microsoft_planner/get_plan_details.ts | 2 ++ apps/sim/tools/microsoft_planner/update_task.ts | 6 ++++-- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/sim/blocks/blocks/microsoft_planner.ts b/apps/sim/blocks/blocks/microsoft_planner.ts index b6c868bae1d..8f8bb9ee7c9 100644 --- a/apps/sim/blocks/blocks/microsoft_planner.ts +++ b/apps/sim/blocks/blocks/microsoft_planner.ts @@ -412,19 +412,21 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, id: 'appliedCategories', title: 'Categories', type: 'short-input', - placeholder: 'e.g. category1,category3', + placeholder: 'e.g. category1,category3,-category5', mode: 'advanced', condition: { field: 'operation', value: ['create_task', 'update_task'] }, wandConfig: { enabled: true, prompt: `Generate a comma-separated list of Microsoft Planner category keys based on the user's description. -Valid keys are category1 through category25. +Valid keys are category1 through category25. Prefix a key with "-" to remove/clear that label (only meaningful on update). Examples: - "flag it blocked" -> category1 - "mark as urgent and needs review" -> category1,category2 +- "remove the blocked label" -> -category1 Return ONLY the comma-separated category keys - no explanations, no extra text.`, - placeholder: 'Describe which category labels to apply (e.g., "mark as blocked")...', + placeholder: + 'Describe which category labels to apply or remove (e.g., "mark as blocked", "remove urgent label")...', }, }, @@ -690,10 +692,10 @@ Return ONLY the comma-separated category keys - no explanations, no extra text.` if (startDateTime?.trim()) { createParams.startDateTime = startDateTime.trim() } - if (priority !== undefined) { + if (priority !== undefined && String(priority).trim() !== '') { createParams.priority = Number(priority) } - if (percentComplete !== undefined) { + if (percentComplete !== undefined && String(percentComplete).trim() !== '') { createParams.percentComplete = Number(percentComplete) } if (assigneeUserId?.trim()) { @@ -732,10 +734,10 @@ Return ONLY the comma-separated category keys - no explanations, no extra text.` if (assigneeUserId?.trim()) { updateParams.assigneeUserId = assigneeUserId.trim() } - if (priority !== undefined) { + if (priority !== undefined && String(priority).trim() !== '') { updateParams.priority = Number(priority) } - if (percentComplete !== undefined) { + if (percentComplete !== undefined && String(percentComplete).trim() !== '') { updateParams.percentComplete = Number(percentComplete) } if (appliedCategories?.trim()) { diff --git a/apps/sim/tools/microsoft_planner/create_task.ts b/apps/sim/tools/microsoft_planner/create_task.ts index 9d9bf0c94ab..9d6e5b43c03 100644 --- a/apps/sim/tools/microsoft_planner/create_task.ts +++ b/apps/sim/tools/microsoft_planner/create_task.ts @@ -165,7 +165,9 @@ export const createTaskTool: ToolConfig< if (categories.length > 0) { body.appliedCategories = Object.fromEntries( - categories.map((category) => [category, true]) + categories.map((category) => + category.startsWith('-') ? [category.slice(1), false] : [category, true] + ) ) } } diff --git a/apps/sim/tools/microsoft_planner/delete_plan.ts b/apps/sim/tools/microsoft_planner/delete_plan.ts index 510286a20d5..afc0c9e1ba3 100644 --- a/apps/sim/tools/microsoft_planner/delete_plan.ts +++ b/apps/sim/tools/microsoft_planner/delete_plan.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { ErrorExtractorId } from '@/tools/error-extractors' import type { MicrosoftPlannerDeletePlanResponse, MicrosoftPlannerToolParams, @@ -15,6 +16,7 @@ export const deletePlanTool: ToolConfig< name: 'Delete Microsoft Planner Plan', description: 'Delete a Microsoft Planner plan', version: '1.0', + errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS, oauth: { required: true, diff --git a/apps/sim/tools/microsoft_planner/get_plan_details.ts b/apps/sim/tools/microsoft_planner/get_plan_details.ts index ad64120676c..dd8dbadcdae 100644 --- a/apps/sim/tools/microsoft_planner/get_plan_details.ts +++ b/apps/sim/tools/microsoft_planner/get_plan_details.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { ErrorExtractorId } from '@/tools/error-extractors' import type { MicrosoftPlannerGetPlanDetailsResponse, MicrosoftPlannerToolParams, @@ -15,6 +16,7 @@ export const getPlanDetailsTool: ToolConfig< name: 'Get Microsoft Planner Plan Details', description: 'Get detailed information about a plan including category descriptions and sharing', version: '1.0', + errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS, oauth: { required: true, diff --git a/apps/sim/tools/microsoft_planner/update_task.ts b/apps/sim/tools/microsoft_planner/update_task.ts index 6a48279e1be..c5e7cd34f0f 100644 --- a/apps/sim/tools/microsoft_planner/update_task.ts +++ b/apps/sim/tools/microsoft_planner/update_task.ts @@ -167,7 +167,7 @@ export const updateTaskTool: ToolConfig< body.percentComplete = params.percentComplete } - if (params.priority !== undefined) { + if (params.priority !== undefined && params.priority !== null) { body.priority = Number(params.priority) } @@ -192,7 +192,9 @@ export const updateTaskTool: ToolConfig< if (categories.length > 0) { body.appliedCategories = Object.fromEntries( - categories.map((category) => [category, true]) + categories.map((category) => + category.startsWith('-') ? [category.slice(1), false] : [category, true] + ) ) } } From b10667f976bf6e50cc4fb93bb2aacc63ca852008 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 7 Jul 2026 11:51:25 -0700 Subject: [PATCH 4/4] fix(microsoft-planner): don't return a stale etag on 204 update_task response Graph's If-Match update changes the resource's etag even when it returns 204 No Content instead of the updated representation. Returning the request's (now-stale) etag as if it were current would let a chained update silently send a wrong If-Match and fail with 412. Return an empty etag instead so update_task's own etag-required guard forces a re-fetch. --- apps/sim/tools/microsoft_planner/update_task.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/sim/tools/microsoft_planner/update_task.ts b/apps/sim/tools/microsoft_planner/update_task.ts index c5e7cd34f0f..696b78ce5bf 100644 --- a/apps/sim/tools/microsoft_planner/update_task.ts +++ b/apps/sim/tools/microsoft_planner/update_task.ts @@ -217,10 +217,14 @@ export const updateTaskTool: ToolConfig< return { success: true, output: { - message: 'Task updated successfully', + // Graph returned no body, so the etag sent in this request is now stale (the + // update changed it) and the actual new value is unknown. Returning it here would + // let a chained update silently reuse a stale If-Match and fail with 412 — leave + // it empty so callers re-fetch the task before their next update. + message: 'Task updated successfully (re-fetch the task to get its current etag)', task: {} as PlannerTask, taskId: params?.taskId?.trim() || '', - etag: params?.etag || '', + etag: '', metadata: { taskId: params?.taskId?.trim(), },