@taskforcehq/taskforce 0.3.327 → 0.3.328

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/dist/Taskforce.module.css +24 -53
  2. package/dist/components/activity/EntityActivityTimeline.js +15 -5
  3. package/dist/components/context/AttachmentPreviewHost.js +1 -1
  4. package/dist/components/context/ContextAttachmentManager.d.ts +3 -0
  5. package/dist/components/context/ContextAttachmentManager.js +116 -20
  6. package/dist/components/features/planning/PlanningEntitySummary.d.ts +3 -1
  7. package/dist/components/features/planning/PlanningEntitySummary.js +3 -2
  8. package/dist/components/features/planning/PlanningLinkPicker.d.ts +3 -1
  9. package/dist/components/features/planning/PlanningLinkPicker.js +5 -3
  10. package/dist/components/features/planning/PlanningModule.js +26 -9
  11. package/dist/components/features/planning/PlanningVisualIdentity.d.ts +14 -0
  12. package/dist/components/features/planning/PlanningVisualIdentity.js +106 -0
  13. package/dist/components/features/planning/planningWorkspaceModel.d.ts +11 -0
  14. package/dist/components/task/TaskActionHeader.d.ts +2 -1
  15. package/dist/components/task/TaskActionHeader.js +2 -2
  16. package/dist/components/task/TaskCard.js +37 -4
  17. package/dist/components/task/TaskContextUpload.d.ts +3 -0
  18. package/dist/components/task/TaskContextUpload.js +2 -2
  19. package/dist/components/task/TaskForm.d.ts +4 -0
  20. package/dist/components/task/TaskForm.js +32 -7
  21. package/dist/components/task/TaskImageReviews.js +16 -16
  22. package/dist/components/task/TaskStatusActions.js +11 -2
  23. package/dist/components/task/TaskWorkstreamPicker.js +2 -1
  24. package/dist/components/ui/DocumentTypeIcon.d.ts +7 -0
  25. package/dist/components/ui/DocumentTypeIcon.js +8 -0
  26. package/dist/components/ui/TaxonomyDropdown.d.ts +9 -1
  27. package/dist/components/ui/TaxonomyDropdown.js +30 -3
  28. package/dist/components/views/StandaloneLayout.js +62 -9
  29. package/dist/components/views/WidgetView.js +8 -5
  30. package/dist/components/views/panels/PlanningDrawer.d.ts +7 -1
  31. package/dist/components/views/panels/PlanningDrawer.js +48 -44
  32. package/dist/config/envSchema.js +1 -0
  33. package/dist/config/localServiceCloudTarget.d.ts +10 -0
  34. package/dist/config/localServiceCloudTarget.js +76 -0
  35. package/dist/core/AiProfileService.d.ts +1 -0
  36. package/dist/core/AiProfileService.js +2 -1
  37. package/dist/core/AttachmentLinkService.js +7 -4
  38. package/dist/core/DeletedTaskLifecycleService.d.ts +4 -0
  39. package/dist/core/DeletedTaskLifecycleService.js +21 -0
  40. package/dist/core/McpTokenService.d.ts +4 -0
  41. package/dist/core/McpTokenService.js +52 -0
  42. package/dist/core/TaskAttachmentLinksDurableMutationService.d.ts +5 -0
  43. package/dist/core/TaskAttachmentLinksDurableMutationService.js +20 -11
  44. package/dist/core/Taskforce.d.ts +63 -1
  45. package/dist/core/Taskforce.js +558 -73
  46. package/dist/core/types.d.ts +2 -1
  47. package/dist/documentReviews/DocumentReviewCommentStore.d.ts +11 -0
  48. package/dist/documentReviews/DocumentReviewCommentStore.js +35 -2
  49. package/dist/documentReviews/service.d.ts +4 -0
  50. package/dist/documentReviews/service.js +141 -46
  51. package/dist/hooks/tasks/useTaskFormActions.d.ts +3 -0
  52. package/dist/hooks/tasks/useTaskFormActions.js +33 -7
  53. package/dist/hooks/useTaskforce.d.ts +59 -50
  54. package/dist/hooks/useTaskforce.js +103 -1
  55. package/dist/mcp/canonicalAssetHelpers.d.ts +13 -0
  56. package/dist/mcp/canonicalAssetHelpers.js +39 -5
  57. package/dist/mcp/collaborationRegistrar.js +15 -13
  58. package/dist/mcp/documentAssetRegistrar.js +16 -9
  59. package/dist/mcp/localServiceProxy.js +4 -1
  60. package/dist/mcp/runtime.d.ts +16 -0
  61. package/dist/mcp/runtime.js +566 -171
  62. package/dist/mcp/taskAttachmentCommandAdapter.d.ts +2 -0
  63. package/dist/mcp/taskAttachmentCommandAdapter.js +14 -8
  64. package/dist/mcp/taskPlanningRegistrar.js +42 -34
  65. package/dist/mcp/toolRegistry.js +2 -2
  66. package/dist/migrations/taskSchemaMigrations.js +35 -0
  67. package/dist/runtime/appGateDefinitions.d.ts +2 -2
  68. package/dist/runtime/appGateDefinitions.js +2 -2
  69. package/dist/server/appAccess.d.ts +12 -0
  70. package/dist/server/appAccess.js +21 -0
  71. package/dist/server/authorizedWorkspaceAccess.d.ts +12 -0
  72. package/dist/server/authorizedWorkspaceAccess.js +40 -0
  73. package/dist/server/cloudCollectionRead.d.ts +25 -0
  74. package/dist/server/cloudCollectionRead.js +120 -0
  75. package/dist/server/cloudCollectionReadPool.d.ts +52 -0
  76. package/dist/server/cloudCollectionReadPool.js +140 -0
  77. package/dist/server/cloudCollectionReadWorker.d.ts +1 -0
  78. package/dist/server/cloudCollectionReadWorker.js +36 -0
  79. package/dist/server/index.d.ts +3 -11
  80. package/dist/server/index.js +53 -40
  81. package/dist/server/localServiceLease.d.ts +3 -0
  82. package/dist/server/localServiceLease.js +9 -1
  83. package/dist/server/routes/documents.d.ts +5 -2
  84. package/dist/server/routes/documents.js +160 -13
  85. package/dist/server/routes/durableTaskHttpRouters.d.ts +2 -0
  86. package/dist/server/routes/durableTaskHttpRouters.js +2 -2
  87. package/dist/server/routes/localService.js +2 -0
  88. package/dist/server/routes/primitives.d.ts +2 -0
  89. package/dist/server/routes/shared.d.ts +24 -0
  90. package/dist/server/routes/shared.js +114 -18
  91. package/dist/server/routes/sync.js +22 -0
  92. package/dist/server/routes/syncV3FeedRoutes.js +4 -2
  93. package/dist/server/routes/syncV3LocalCaptureRoutes.js +20 -2
  94. package/dist/server/routes/syncV3OperationRoutes.js +17 -2
  95. package/dist/server/routes/tasks.js +199 -89
  96. package/dist/server/routes.js +35 -7
  97. package/dist/service/localServiceCli.js +36 -25
  98. package/dist/service/localServiceClientLifecycle.js +67 -6
  99. package/dist/shared/planningIdentity.d.ts +20 -0
  100. package/dist/shared/planningIdentity.js +79 -0
  101. package/dist/storage/postgresAdapter.js +4 -0
  102. package/dist/storage/postgresRequestDiagnostics.d.ts +8 -0
  103. package/dist/storage/postgresRequestDiagnostics.js +24 -0
  104. package/dist/storage/workspaceAssetStore.d.ts +32 -0
  105. package/dist/storage/workspaceAssetStore.js +70 -0
  106. package/dist/sync/coordinator/localSyncExecutionPermitStore.d.ts +16 -0
  107. package/dist/sync/coordinator/localSyncExecutionPermitStore.js +70 -22
  108. package/dist/sync/coordinator/workspaceSyncTransportGateway.js +4 -2
  109. package/dist/sync/engine/workspaceSyncEngineRemoteServices.d.ts +5 -0
  110. package/dist/sync/engine/workspaceSyncEngineServerRemoteServices.d.ts +1 -1
  111. package/dist/sync/engine/workspaceSyncEngineServerRemoteServices.js +15 -0
  112. package/dist/sync/engine/workspaceSyncEngineServerRunnerOperations.js +26 -3
  113. package/dist/sync/engine/workspaceSyncV2ChangeDispatcher.js +2 -0
  114. package/dist/sync/syncApplyHandlers.d.ts +3 -1
  115. package/dist/sync/syncApplyHandlers.js +29 -0
  116. package/dist/sync/syncService.js +9 -0
  117. package/dist/sync/taskSyncPayload.d.ts +1 -0
  118. package/dist/sync/taskSyncPayload.js +6 -0
  119. package/dist/sync/v3/durableTaskAttachmentLinksMutationRouter.d.ts +1 -0
  120. package/dist/sync/v3/durableTaskAttachmentLinksMutationRouter.js +21 -3
  121. package/dist/sync/v3/durableTaskHttpBulkMutationRouter.js +17 -4
  122. package/dist/sync/v3/localInitiativeOutboxService.js +4 -2
  123. package/dist/sync/v3/localOutboxDispatcher.js +5 -1
  124. package/dist/sync/v3/localTaskAttachmentLinksOutboxHandler.js +12 -2
  125. package/dist/sync/v3/localTaskAttachmentLinksOutboxService.d.ts +1 -0
  126. package/dist/sync/v3/localTaskAttachmentLinksOutboxService.js +15 -3
  127. package/dist/sync/v3/localWorkstreamOutboxService.js +4 -2
  128. package/dist/sync/v3/syncDurabilityStore.d.ts +8 -0
  129. package/dist/sync/v3/syncDurabilityStore.js +171 -33
  130. package/dist/sync/v3/taskAttachmentLinksMutationService.d.ts +1 -0
  131. package/dist/sync/v3/taskAttachmentLinksMutationService.js +18 -2
  132. package/dist/sync/v3/taskMetadataCoexistence.js +4 -0
  133. package/dist/sync/v3/workspaceSyncV2ProjectionExclusions.js +1 -9
  134. package/dist/sync/v3/workspaceSyncV3CloudOperationHandler.js +1 -0
  135. package/dist/sync/v3/workspaceSyncV3CombinedFeedOrchestrator.js +7 -1
  136. package/dist/sync/v3/workspaceSyncV3CombinedLocalApply.js +15 -3
  137. package/dist/sync/v3/workspaceSyncV3CombinedRepairBaseline.d.ts +4 -0
  138. package/dist/sync/v3/workspaceSyncV3CombinedRepairBaseline.js +29 -6
  139. package/dist/sync/v3/workspaceSyncV3CombinedRepairMaterialization.d.ts +1 -0
  140. package/dist/sync/v3/workspaceSyncV3CombinedRepairMaterialization.js +29 -7
  141. package/dist/sync/v3/workspaceSyncV3CombinedRepairSnapshot.js +62 -7
  142. package/dist/sync/v3/workspaceSyncV3OperationProtocol.d.ts +1 -0
  143. package/dist/sync/v3/workspaceSyncV3RepairSqlBatching.d.ts +2 -0
  144. package/dist/sync/v3/workspaceSyncV3RepairSqlBatching.js +10 -0
  145. package/dist/sync/v3/workspaceSyncV3TaskRootProjection.d.ts +1 -1
  146. package/dist/sync/v3/workspaceSyncV3TaskRootProjection.js +1 -1
  147. package/dist/sync/workspacePullFeed.d.ts +6 -1
  148. package/dist/sync/workspacePullFeed.js +172 -161
  149. package/dist/sync/workspaceSyncModel.d.ts +11 -0
  150. package/dist/sync/workspaceSyncModel.js +27 -2
  151. package/dist/types.d.ts +8 -0
  152. package/dist/ui/assets/{AiIdentityRosterCard-DB1BLO_j.js → AiIdentityRosterCard-MZs7lVED.js} +1 -1
  153. package/dist/ui/assets/{AiProfilesModule-DPxQeF9N.js → AiProfilesModule-C3i0LOxe.js} +1 -1
  154. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-DHNYaNg6.js → AnnotatedAttachmentWorkspace-DVZk5Tln.js} +1 -1
  155. package/dist/ui/assets/AssetTraySortControl-Kf1pelSy.js +1 -0
  156. package/dist/ui/assets/ContextAttachmentManager-B8boOs6Q.js +3 -0
  157. package/dist/ui/assets/{DocumentWorkspace-BC7sy5TG.js → DocumentWorkspace-DXH4XUsB.js} +4 -4
  158. package/dist/ui/assets/EntityActivityTimeline-BtSTq0ON.js +1 -0
  159. package/dist/ui/assets/PlanningModule-BPVyEa-f.js +1 -0
  160. package/dist/ui/assets/PlanningModule-CoIR9hbV.css +1 -0
  161. package/dist/ui/assets/{PlansPage-BVH_NfgT.js → PlansPage-ChqgxALc.js} +1 -1
  162. package/dist/ui/assets/TaskContextUpload-CB3ol0sN.js +1 -0
  163. package/dist/ui/assets/TaskContextUpload-qmBeUw3u.css +1 -0
  164. package/dist/ui/assets/{TaskSettings-xVsxSa0g.js → TaskSettings-g4ECKNOz.js} +1 -1
  165. package/dist/ui/assets/{TaskforceAgentsModule-CkAxW19K.js → TaskforceAgentsModule-BazdUwxZ.js} +1 -1
  166. package/dist/ui/assets/WorkflowManagerModule-DWFspC-o.js +1 -0
  167. package/dist/ui/assets/index-BUplSsv_.js +7 -0
  168. package/dist/ui/assets/index-gz2EXuoK.css +1 -0
  169. package/dist/ui/assets/{vendor-icons-B8ZNsH1g.js → vendor-icons-BkFLXavV.js} +1 -1
  170. package/dist/ui/index.html +3 -3
  171. package/dist/utils/constants.d.ts +2 -2
  172. package/dist/utils/constants.js +1 -1
  173. package/dist/utils/contextAttachmentUpload.d.ts +2 -0
  174. package/dist/utils/contextAttachmentUpload.js +7 -0
  175. package/dist/utils/contextFiles.d.ts +1 -1
  176. package/dist/utils/contextFiles.js +4 -4
  177. package/package.json +6 -1
  178. package/dist/ui/assets/AssetTraySortControl-DphbyHVc.js +0 -1
  179. package/dist/ui/assets/ContextAttachmentManager-CMxSzHqc.js +0 -3
  180. package/dist/ui/assets/EntityActivityTimeline-pJ2ZjZ-k.js +0 -1
  181. package/dist/ui/assets/PlanningModule-CeTqXMMz.css +0 -1
  182. package/dist/ui/assets/PlanningModule-t5GbvVR9.js +0 -1
  183. package/dist/ui/assets/TaskContextUpload-Ds4LnDzV.css +0 -1
  184. package/dist/ui/assets/TaskContextUpload-bl4fOOUz.js +0 -1
  185. package/dist/ui/assets/WorkflowManagerModule-BKqIGVnF.js +0 -1
  186. package/dist/ui/assets/index-BzJlVnNS.css +0 -1
  187. package/dist/ui/assets/index-Cx7vZJHL.js +0 -7
@@ -16,7 +16,7 @@ import { AnnotatedAttachmentCommandError, createAnnotatedAttachmentCommandServic
16
16
  import { createDocumentReviewCommandService, DocumentReviewCommandError, } from '../documentReviews/commandService.js';
17
17
  import { COMPLEXITY_OPTIONS, resolveNumberOption, resolveStringOption, STATUS_OPTIONS } from './optionResolution.js';
18
18
  import { buildResolveAiProfileInputFromBootstrap } from './aiProfileBootstrap.js';
19
- import { createCanonicalAssetHelpers } from './canonicalAssetHelpers.js';
19
+ import { CanonicalDocumentHeadStaleError, createCanonicalAssetHelpers } from './canonicalAssetHelpers.js';
20
20
  import { createDocumentHelpers } from './documentHelpers.js';
21
21
  import { createTaskAttachmentHelpers } from './taskAttachmentHelpers.js';
22
22
  import { listRegisteredGitWorktreeRoots } from './gitWorktreeRoots.js';
@@ -59,7 +59,7 @@ import { createTaskPlanCommand } from './taskPlanCommand.js';
59
59
  import { WorkflowAssignmentService } from '../core/WorkflowAssignmentService.js';
60
60
  import { WorkflowExecutionService, WorkflowExecutionServiceError, } from '../core/WorkflowExecutionService.js';
61
61
  import { WorkflowStore } from '../storage/workflowStore.js';
62
- import { getMcpClientRegistryEntry, normalizeMcpConnectionId, parseMcpClientId } from './clientRegistry.js';
62
+ import { getMcpClientRegistryEntry, inferLegacyMcpClientIdFromProfile, normalizeMcpConnectionId, parseMcpClientId } from './clientRegistry.js';
63
63
  import { BedrockAvatarImageProvider } from '../services/avatarImageProvider.js';
64
64
  import { AiProfileAvatarGenerationError, checkAiProfileAvatarGenerationRateLimit, generateAiProfileAvatar, } from '../services/aiProfileAvatarGenerationService.js';
65
65
  import { isAiProfileAvatarGenerationEligible } from '../shared/aiProfileAvatarEligibility.js';
@@ -410,6 +410,22 @@ function resolveRecoverableToolError(error, context) {
410
410
  };
411
411
  }
412
412
  if (error instanceof TaskforceRuleError) {
413
+ if (code === 'AI_PROFILE_SELECTION_REQUIRED') {
414
+ const selectionDetails = details && typeof details === 'object' && !Array.isArray(details)
415
+ ? details
416
+ : {};
417
+ const suggestedArgs = selectionDetails.suggestedArgs;
418
+ return {
419
+ message,
420
+ code,
421
+ details,
422
+ nextTool: 'resolve_profile',
423
+ ...(suggestedArgs && typeof suggestedArgs === 'object' && !Array.isArray(suggestedArgs)
424
+ ? { suggestedArgs: suggestedArgs }
425
+ : {}),
426
+ retryHint: 'Choose the intended compatible profile candidate and retry resolve_profile with the same name plus its profileId. If more than one candidate is present and the intended identity is unclear, ask the user before selecting.',
427
+ };
428
+ }
413
429
  if (code === 'AI_PROFILE_AVATAR_PROVIDER_FAILED' || code === 'AI_PROFILE_AVATAR_PERSISTENCE_FAILED') {
414
430
  return {
415
431
  message,
@@ -677,6 +693,14 @@ export function buildUnexpectedToolErrorResult(requestId) {
677
693
  isError: true,
678
694
  };
679
695
  }
696
+ function measureToolResultBytes(result) {
697
+ try {
698
+ return Buffer.byteLength(JSON.stringify(result), 'utf8');
699
+ }
700
+ catch {
701
+ return 0;
702
+ }
703
+ }
680
704
  export function buildBatchToolError(error, fallbackStatusCode) {
681
705
  const resolved = resolveRecoverableToolError(error);
682
706
  if (!resolved) {
@@ -786,6 +810,29 @@ export function createTaskforceMcpServer(options = {}) {
786
810
  }
787
811
  const mcpRequestIdentity = new AsyncLocalStorage();
788
812
  const cloudCreateRequestIdentity = new AsyncLocalStorage();
813
+ const mcpToolSubphaseTimings = new AsyncLocalStorage();
814
+ const measureToolSubphase = (phase, operation) => {
815
+ const startedAtMs = Date.now();
816
+ try {
817
+ return operation();
818
+ }
819
+ finally {
820
+ const timings = mcpToolSubphaseTimings.getStore();
821
+ if (timings)
822
+ timings[phase] = (timings[phase] || 0) + (Date.now() - startedAtMs);
823
+ }
824
+ };
825
+ const measureToolSubphaseAsync = async (phase, operation) => {
826
+ const startedAtMs = Date.now();
827
+ try {
828
+ return await operation();
829
+ }
830
+ finally {
831
+ const timings = mcpToolSubphaseTimings.getStore();
832
+ if (timings)
833
+ timings[phase] = (timings[phase] || 0) + (Date.now() - startedAtMs);
834
+ }
835
+ };
789
836
  const operationIdentityScope = options.operationIdentityScope
790
837
  ? {
791
838
  generation: String(options.operationIdentityScope.generation || '').trim(),
@@ -824,28 +871,34 @@ export function createTaskforceMcpServer(options = {}) {
824
871
  return null;
825
872
  return `${kind}-${createHash('sha256').update(`${kind}:${requestIdentity}:${discriminator || ''}`).digest('hex').slice(0, 32)}`;
826
873
  };
827
- const stableTaskCreateIdempotencyId = (kind, idempotencyKey, discriminator) => {
828
- const fingerprint = operationIdentityScope
829
- ? fingerprintWorkspaceSyncPayload({
830
- kind,
831
- tenantId,
832
- workspaceId: ACTIVE_WORKSPACE_ID,
833
- actorRef: getActiveMcpActorRef(),
834
- tool: 'create_task',
835
- idempotencyKey,
836
- discriminator: discriminator || '',
837
- })
838
- : createHash('sha256').update(JSON.stringify([
839
- kind,
840
- tenantId,
841
- ACTIVE_WORKSPACE_ID,
842
- getActiveMcpActorRef(),
843
- 'create_task',
844
- idempotencyKey,
845
- discriminator || '',
846
- ])).digest('hex');
874
+ const normalizeExplicitIdempotencyKey = (value) => {
875
+ if (value === undefined || value === null)
876
+ return null;
877
+ const key = String(value).trim();
878
+ if (!key)
879
+ throw new McpPublicError('idempotencyKey must contain non-whitespace text.');
880
+ if (key.length > 128)
881
+ throw new McpPublicError('idempotencyKey must contain at most 128 characters.');
882
+ return key;
883
+ };
884
+ const stableExplicitMutationId = (kind, tool, idempotencyKey, discriminator) => {
885
+ const fingerprint = createHash('sha256').update(JSON.stringify([
886
+ kind,
887
+ tenantId,
888
+ ACTIVE_WORKSPACE_ID,
889
+ getActiveMcpActorRef(),
890
+ tool,
891
+ idempotencyKey,
892
+ discriminator || '',
893
+ ])).digest('hex');
847
894
  return `${kind}-${fingerprint.slice(0, 32)}`;
848
895
  };
896
+ const stableTaskCreateIdempotencyId = (kind, idempotencyKey, discriminator) => stableExplicitMutationId(kind, 'create_task', idempotencyKey, discriminator);
897
+ const requireDurableExplicitIdempotency = (enabled, tool) => {
898
+ if (enabled)
899
+ return;
900
+ throw new McpPublicError(`idempotencyKey requires the durable ${tool} mutation boundary, which is unavailable for this runtime.`, 'MCP_IDEMPOTENCY_UNAVAILABLE', 503);
901
+ };
849
902
  const durableTaskMutationRouter = options.durableTaskMutationRouter || createDurableTaskMutationRouter({
850
903
  core,
851
904
  runtimeMode,
@@ -929,7 +982,8 @@ export function createTaskforceMcpServer(options = {}) {
929
982
  cloudClientId: 'mcp-cloud',
930
983
  createOperationId: () => mutationOptions?.operationId
931
984
  || requireStableMcpRequestId('operation', 'initiative'),
932
- createCommentId: () => requireStableMcpRequestId('comment', 'initiative-comment'),
985
+ createCommentId: () => mutationOptions?.commentId
986
+ || requireStableMcpRequestId('comment', 'initiative-comment'),
933
987
  deferPostCommitNotification: mutationOptions?.deferPostCommitNotification,
934
988
  });
935
989
  const initiativeFromDurableResult = (result) => result.state === 'pending' ? result.provisionalInitiative : result.authoritativeInitiative;
@@ -946,7 +1000,8 @@ export function createTaskforceMcpServer(options = {}) {
946
1000
  cloudClientId: 'mcp-cloud',
947
1001
  createOperationId: () => mutationOptions?.operationId
948
1002
  || requireStableMcpRequestId('operation', 'workstream'),
949
- createCommentId: () => requireStableMcpRequestId('comment', 'workstream-comment'),
1003
+ createCommentId: () => mutationOptions?.commentId
1004
+ || requireStableMcpRequestId('comment', 'workstream-comment'),
950
1005
  deferPostCommitNotification: mutationOptions?.deferPostCommitNotification,
951
1006
  });
952
1007
  const workstreamFromDurableResult = (result) => result.state === 'pending' ? result.provisionalWorkstream : result.authoritativeWorkstream;
@@ -1049,7 +1104,9 @@ export function createTaskforceMcpServer(options = {}) {
1049
1104
  legacyProvider: asset.storageProvider,
1050
1105
  });
1051
1106
  return resolved.source === 'versioned'
1052
- && resolved.contentSha256 === asset.contentSha256;
1107
+ && resolved.storageProvider === asset.storageProvider
1108
+ && resolved.contentSha256 === asset.contentSha256
1109
+ && resolved.sizeBytes === asset.sizeBytes;
1053
1110
  }
1054
1111
  catch {
1055
1112
  return false;
@@ -1243,7 +1300,13 @@ export function createTaskforceMcpServer(options = {}) {
1243
1300
  }
1244
1301
  return { id: task.id, task };
1245
1302
  }
1246
- const buildDocumentDescriptor = (asset, options) => (withDocumentUrl(documentHelpers.buildDocumentDescriptor(asset, options)));
1303
+ const buildDocumentDescriptor = (asset, options) => {
1304
+ const descriptor = withDocumentUrl(documentHelpers.buildDocumentDescriptor(asset, options));
1305
+ if (runtimeMode === 'local')
1306
+ return descriptor;
1307
+ const { storageKey: _storageKey, ...cloudDescriptor } = descriptor;
1308
+ return cloudDescriptor;
1309
+ };
1247
1310
  const buildCanonicalDocumentAttachmentHint = documentHelpers.buildCanonicalDocumentAttachmentHint;
1248
1311
  function buildBoundedTextContent(content, maxChars) {
1249
1312
  const requestedMaxChars = Number(maxChars);
@@ -1271,7 +1334,19 @@ export function createTaskforceMcpServer(options = {}) {
1271
1334
  format: "metadata"
1272
1335
  };
1273
1336
  }
1337
+ const assertCurrentHead = () => {
1338
+ try {
1339
+ canonicalAssetHelpers.assertCanonicalDocumentHeadCurrent(asset);
1340
+ }
1341
+ catch (error) {
1342
+ if (error instanceof CanonicalDocumentHeadStaleError) {
1343
+ throw new McpPublicError(error.message, error.code, error.statusCode, error.details);
1344
+ }
1345
+ throw error;
1346
+ }
1347
+ };
1274
1348
  if (format === "download") {
1349
+ assertCurrentHead();
1275
1350
  return {
1276
1351
  format: "download",
1277
1352
  downloadUrl: canonicalAssetHelpers.buildDocumentDownloadUrl(asset),
@@ -1279,6 +1354,7 @@ export function createTaskforceMcpServer(options = {}) {
1279
1354
  };
1280
1355
  }
1281
1356
  if (!isTextReadableMimeType(asset.mimeType || '')) {
1357
+ assertCurrentHead();
1282
1358
  return {
1283
1359
  format: "download",
1284
1360
  downloadUrl: canonicalAssetHelpers.buildDocumentDownloadUrl(asset),
@@ -1290,6 +1366,9 @@ export function createTaskforceMcpServer(options = {}) {
1290
1366
  buffer = await canonicalAssetHelpers.readCanonicalDocumentBuffer(asset);
1291
1367
  }
1292
1368
  catch (error) {
1369
+ if (error instanceof CanonicalDocumentHeadStaleError) {
1370
+ throw new McpPublicError(error.message, error.code, error.statusCode, error.details);
1371
+ }
1293
1372
  const message = error instanceof Error ? String(error.message || '').trim() : '';
1294
1373
  if (/^Document .+ not found in workspace /i.test(message)) {
1295
1374
  throw new McpPublicError(message, 'MCP_NOT_FOUND', 404);
@@ -1384,7 +1463,7 @@ export function createTaskforceMcpServer(options = {}) {
1384
1463
  originalFilename: asset.originalFilename ?? null,
1385
1464
  mimeType: asset.mimeType ?? null,
1386
1465
  sizeBytes: typeof asset.sizeBytes === 'number' ? asset.sizeBytes : null,
1387
- storageKey: asset.storageKey ?? null,
1466
+ ...(runtimeMode === 'local' ? { storageKey: asset.storageKey ?? null } : {}),
1388
1467
  updatedAt: asset.updatedAt ?? null,
1389
1468
  apiUrl: `/api/taskforce/context/${encodeURIComponent(String(asset.storageKey || '').trim())}`,
1390
1469
  };
@@ -1396,6 +1475,9 @@ export function createTaskforceMcpServer(options = {}) {
1396
1475
  }
1397
1476
  catch (error) {
1398
1477
  const requestedIdentifier = typeof documentIdentifier === 'string' ? documentIdentifier.trim() : '';
1478
+ if (!requestedIdentifier) {
1479
+ throw new McpPublicError('documentId is required', 'MCP_INVALID_ARGUMENT', 400);
1480
+ }
1399
1481
  if (requestedIdentifier && /not found in workspace/i.test(String(error?.message || error))) {
1400
1482
  throw new TaskforceRuleError(String(error?.message || error), 404, 'DOCUMENT_NOT_FOUND', {
1401
1483
  requestedIdentifier,
@@ -1439,6 +1521,7 @@ export function createTaskforceMcpServer(options = {}) {
1439
1521
  const auditActorUserId = String(options.auth?.userId || (runtimeMode === 'cloud' ? 'anonymous' : 'local-mcp')).trim() || 'anonymous';
1440
1522
  const auditTokenId = options.auth?.tokenId ? String(options.auth.tokenId).trim() : null;
1441
1523
  const configuredAiProfileId = options.auth?.aiProfileId ? String(options.auth.aiProfileId).trim() || null : null;
1524
+ const configuredAiProfileIdentityAsserted = options.auth?.aiProfileIdentityAsserted === true;
1442
1525
  const configuredAiProfileToken = options.auth?.aiProfileToken ? String(options.auth.aiProfileToken).trim() || null : null;
1443
1526
  const configuredAiProfileName = options.auth?.aiProfileName ? String(options.auth.aiProfileName).trim() || null : null;
1444
1527
  const configuredAiProfileIcon = options.auth?.aiProfileIcon ? String(options.auth.aiProfileIcon).trim() || null : null;
@@ -1456,6 +1539,54 @@ export function createTaskforceMcpServer(options = {}) {
1456
1539
  const auditUserAgent = options.auth?.userAgent ? String(options.auth.userAgent).trim() || null : null;
1457
1540
  let activeAiProfileId = null;
1458
1541
  let activeAiBindingSource = null;
1542
+ let aiProfileIdentityConflictDetected = false;
1543
+ function failAiProfileBindingConflict(message, details) {
1544
+ aiProfileIdentityConflictDetected = true;
1545
+ let credentialRevoked = false;
1546
+ if (auditActorType === 'oauth' && auditTokenId) {
1547
+ core.revokeMcpOAuthConnectorGrant({
1548
+ actorUserId: auditActorUserId,
1549
+ workspaceId: ACTIVE_WORKSPACE_ID,
1550
+ grantId: auditTokenId,
1551
+ });
1552
+ credentialRevoked = true;
1553
+ }
1554
+ throw new TaskforceRuleError(credentialRevoked ? `${message} The conflicting credential was revoked; reconnect before retrying.` : message, 409, 'AI_PROFILE_BINDING_CONFLICT', { ...details, credentialRevoked, nextTool: 'resolve_profile' });
1555
+ }
1556
+ function assertRequestedIdentityMatchesBoundProfile(profile, input) {
1557
+ if (auditActorType !== 'oauth')
1558
+ return;
1559
+ const requestedName = String(input.name || '').trim();
1560
+ const requestedNameKey = requestedName.toLowerCase();
1561
+ const requestedUsernameKey = normalizeConfiguredAiProfileUsername(undefined, requestedName).toLowerCase();
1562
+ const boundNameKey = String(profile.name || '').trim().toLowerCase();
1563
+ const boundUsernameKey = String(profile.username || '').trim().toLowerCase();
1564
+ const nameMatches = Boolean(requestedNameKey) && (requestedNameKey === boundNameKey
1565
+ || requestedNameKey === boundUsernameKey
1566
+ || requestedUsernameKey === boundUsernameKey);
1567
+ const normalizeProvider = (value) => String(value || '')
1568
+ .trim()
1569
+ .toLowerCase()
1570
+ .replace(/[^a-z0-9]+/g, '');
1571
+ const requestedProvider = normalizeProvider(input.provider);
1572
+ const boundProvider = normalizeProvider(profile.provider);
1573
+ const providerConflicts = Boolean(requestedProvider && boundProvider && requestedProvider !== boundProvider);
1574
+ if (nameMatches && !providerConflicts)
1575
+ return;
1576
+ failAiProfileBindingConflict('The requested AI identity conflicts with the profile already bound to this OAuth connection.', {
1577
+ reason: 'requested_identity_mismatch',
1578
+ boundProfile: {
1579
+ id: profile.id,
1580
+ name: profile.name,
1581
+ username: profile.username,
1582
+ provider: profile.provider || null,
1583
+ },
1584
+ requestedIdentity: {
1585
+ name: requestedName || null,
1586
+ provider: String(input.provider || '').trim() || null,
1587
+ },
1588
+ });
1589
+ }
1459
1590
  function applyConfiguredMcpClientContext(profileId) {
1460
1591
  const shouldBindOAuthGrant = auditActorType === 'oauth'
1461
1592
  && Boolean(auditTokenId)
@@ -1541,6 +1672,7 @@ export function createTaskforceMcpServer(options = {}) {
1541
1672
  workspaceId: ACTIVE_WORKSPACE_ID,
1542
1673
  connectionId: configuredMcpConnectionId,
1543
1674
  seatScope: configuredAiProfileSeatScope,
1675
+ repairLegacyBinding: options?.repairConnection !== false,
1544
1676
  });
1545
1677
  if (!match && auditActorType !== 'oauth' && configuredMcpClientId && configuredMcpClientId !== 'general') {
1546
1678
  const candidates = core.listAiProfiles(ACTIVE_WORKSPACE_ID).filter((profile) => {
@@ -1551,12 +1683,14 @@ export function createTaskforceMcpServer(options = {}) {
1551
1683
  && connection.clientId === configuredMcpClientId);
1552
1684
  });
1553
1685
  if (candidates.length === 1) {
1554
- match = core.bindAiProfileConnection({
1555
- workspaceId: ACTIVE_WORKSPACE_ID,
1556
- profileId: candidates[0].id,
1557
- connectionId: configuredMcpConnectionId,
1558
- clientId: configuredMcpClientId,
1559
- });
1686
+ match = options?.repairConnection === false
1687
+ ? candidates[0]
1688
+ : core.bindAiProfileConnection({
1689
+ workspaceId: ACTIVE_WORKSPACE_ID,
1690
+ profileId: candidates[0].id,
1691
+ connectionId: configuredMcpConnectionId,
1692
+ clientId: configuredMcpClientId,
1693
+ });
1560
1694
  }
1561
1695
  }
1562
1696
  if (match?.archivedAt) {
@@ -1575,6 +1709,8 @@ export function createTaskforceMcpServer(options = {}) {
1575
1709
  mergeTarget = next;
1576
1710
  }
1577
1711
  if (!mergeTarget.archivedAt) {
1712
+ if (options?.repairConnection === false)
1713
+ return mergeTarget;
1578
1714
  return core.bindAiProfileConnection({
1579
1715
  workspaceId: ACTIVE_WORKSPACE_ID,
1580
1716
  profileId: mergeTarget.id,
@@ -1622,7 +1758,7 @@ export function createTaskforceMcpServer(options = {}) {
1622
1758
  const grantProfile = findProfileForConfiguredGrant();
1623
1759
  const connectionProfile = findProfileForConfiguredConnection(options);
1624
1760
  if (grantProfile && connectionProfile && grantProfile.id !== connectionProfile.id) {
1625
- throw new TaskforceRuleError('The authenticated OAuth grant and MCP connection are bound to different AI profiles. Select the intended profile in Taskforce before retrying.', 409, 'AI_PROFILE_BINDING_CONFLICT', { grantProfileId: grantProfile.id, connectionProfileId: connectionProfile.id });
1761
+ failAiProfileBindingConflict('The authenticated OAuth grant and MCP connection are bound to different AI profiles. Select the intended profile in Taskforce before retrying.', { reason: 'grant_connection_mismatch', grantProfileId: grantProfile.id, connectionProfileId: connectionProfile.id });
1626
1762
  }
1627
1763
  const profile = grantProfile || connectionProfile;
1628
1764
  if (profile && configuredMcpConnectionId && !connectionProfile && options?.repairConnection !== false) {
@@ -1635,6 +1771,38 @@ export function createTaskforceMcpServer(options = {}) {
1635
1771
  }
1636
1772
  return profile;
1637
1773
  }
1774
+ function listSelectableOAuthProfiles(profileName) {
1775
+ if (auditActorType !== 'oauth')
1776
+ return [];
1777
+ const requestedName = String(profileName || '').trim().toLowerCase();
1778
+ if (!requestedName)
1779
+ return [];
1780
+ const requestedUsername = normalizeConfiguredAiProfileUsername(undefined, requestedName).toLowerCase();
1781
+ const restorableProfileIds = !configuredMcpClientId && auditTokenId
1782
+ ? new Set(core.listMcpOAuthRestorableAiProfileIds({
1783
+ grantId: auditTokenId,
1784
+ workspaceId: ACTIVE_WORKSPACE_ID,
1785
+ }))
1786
+ : null;
1787
+ return core.listAiProfiles(ACTIVE_WORKSPACE_ID).filter((profile) => {
1788
+ if ((profile.seatScope ?? null) !== configuredAiProfileSeatScope)
1789
+ return false;
1790
+ const profileNameMatches = profile.name.trim().toLowerCase() === requestedName
1791
+ || profile.username.trim().toLowerCase() === requestedUsername;
1792
+ if (!profileNameMatches)
1793
+ return false;
1794
+ const connection = profile.providerMetadata?.connection;
1795
+ const storedClientId = connection && typeof connection === 'object' && !Array.isArray(connection)
1796
+ ? parseMcpClientId(connection.clientId)
1797
+ : null;
1798
+ const inferredClientId = inferLegacyMcpClientIdFromProfile(profile);
1799
+ if (!configuredMcpClientId)
1800
+ return restorableProfileIds?.has(profile.id) === true;
1801
+ return storedClientId
1802
+ ? storedClientId === configuredMcpClientId
1803
+ : inferredClientId === configuredMcpClientId;
1804
+ });
1805
+ }
1638
1806
  function getEligibleAssigneeActors() {
1639
1807
  return core.listAssignableActors(ACTIVE_WORKSPACE_ID)
1640
1808
  .filter((actor) => String(actor.id || '').trim().toLowerCase() !== 'agent')
@@ -1646,7 +1814,7 @@ export function createTaskforceMcpServer(options = {}) {
1646
1814
  .filter((actor) => Boolean(actor.id));
1647
1815
  }
1648
1816
  function describeEligibleAssignees() {
1649
- return "Use 'self' for the active AI profile, 'unassigned' to clear ownership, or an eligible ID from list_assignees.";
1817
+ return "Use 'self', 'unassigned', or an ID from list_assignees.";
1650
1818
  }
1651
1819
  function buildEligibleAssigneePayload() {
1652
1820
  return core.listAssignableActors(ACTIVE_WORKSPACE_ID)
@@ -2215,8 +2383,14 @@ export function createTaskforceMcpServer(options = {}) {
2215
2383
  function ensureResolvedProfileFromConnection(defaults) {
2216
2384
  if (activeAiProfileId)
2217
2385
  return;
2218
- const boundProfile = findTrustedBoundProfile();
2386
+ const boundProfile = findTrustedBoundProfile({ repairConnection: false });
2219
2387
  if (boundProfile) {
2388
+ if (configuredAiProfileIdentityAsserted) {
2389
+ assertRequestedIdentityMatchesBoundProfile(boundProfile, {
2390
+ name: configuredAiProfileName,
2391
+ provider: configuredAiProfileProvider,
2392
+ });
2393
+ }
2220
2394
  const configuredClientEntry = getMcpClientRegistryEntry(configuredMcpClientId);
2221
2395
  const resolved = core.resolveAiProfile({
2222
2396
  workspaceId: ACTIVE_WORKSPACE_ID,
@@ -2279,6 +2453,9 @@ export function createTaskforceMcpServer(options = {}) {
2279
2453
  return;
2280
2454
  if (!isMcpWriteToolName(name))
2281
2455
  return;
2456
+ if (aiProfileIdentityConflictDetected) {
2457
+ throw new TaskforceRuleError('Writes are blocked because this MCP session requested an identity that conflicts with its OAuth-bound profile. Reconnect with the intended profile and retry resolve_profile.', 409, 'AI_PROFILE_BINDING_CONFLICT', { reason: 'session_identity_quarantined', nextTool: 'resolve_profile' });
2458
+ }
2282
2459
  const profileToken = resolveProfileTokenFromArgs(args);
2283
2460
  if (activeAiProfileId) {
2284
2461
  if (!profileToken)
@@ -2292,7 +2469,7 @@ export function createTaskforceMcpServer(options = {}) {
2292
2469
  throw new McpPublicError('Invalid AI profile token. Call resolve_profile again and retry.', 'INVALID_AI_PROFILE_TOKEN', 401);
2293
2470
  }
2294
2471
  if (tokenProfile.id !== activeAiProfileId) {
2295
- throw new TaskforceRuleError('The supplied profile token conflicts with the AI profile already bound to this MCP session.', 409, 'AI_PROFILE_BINDING_CONFLICT', { boundProfileId: activeAiProfileId, suppliedProfileId: tokenProfile.id });
2472
+ failAiProfileBindingConflict('The supplied profile token conflicts with the AI profile already bound to this MCP session.', { reason: 'session_token_mismatch', boundProfileId: activeAiProfileId, suppliedProfileId: tokenProfile.id });
2296
2473
  }
2297
2474
  return;
2298
2475
  }
@@ -2306,6 +2483,20 @@ export function createTaskforceMcpServer(options = {}) {
2306
2483
  seatScope: configuredAiProfileSeatScope,
2307
2484
  });
2308
2485
  if (profile) {
2486
+ const trustedBoundProfile = findTrustedBoundProfile({ repairConnection: false });
2487
+ if (trustedBoundProfile && trustedBoundProfile.id !== profile.id) {
2488
+ failAiProfileBindingConflict('The supplied profile token conflicts with the profile already bound to this OAuth grant or MCP connection.', {
2489
+ reason: 'stateless_token_mismatch',
2490
+ boundProfileId: trustedBoundProfile.id,
2491
+ suppliedProfileId: profile.id,
2492
+ });
2493
+ }
2494
+ if (trustedBoundProfile && configuredAiProfileIdentityAsserted) {
2495
+ assertRequestedIdentityMatchesBoundProfile(trustedBoundProfile, {
2496
+ name: configuredAiProfileName,
2497
+ provider: configuredAiProfileProvider,
2498
+ });
2499
+ }
2309
2500
  activateAiProfile(profile.id, 'token_lookup');
2310
2501
  return;
2311
2502
  }
@@ -3049,15 +3240,11 @@ export function createTaskforceMcpServer(options = {}) {
3049
3240
  || planningSearchIncludes(initiative?.referenceLabel || formatInitiativeReference(initiative?.referenceNumber), query)
3050
3241
  || planningSearchIncludes(initiative?.title, query);
3051
3242
  }
3052
- function buildHistoricalWorkspaceTaskSnapshot() {
3053
- const activeTasks = core.listTasks(ACTIVE_WORKSPACE_ID).tasks;
3054
- const archivedTasks = core.listArchive(ACTIVE_WORKSPACE_ID).archived;
3055
- const byId = new Map();
3056
- for (const task of activeTasks)
3057
- byId.set(task.id, task);
3058
- for (const task of archivedTasks)
3059
- byId.set(task.id, task);
3060
- const tasks = Array.from(byId.values());
3243
+ function buildHistoricalWorkspaceTaskSnapshot(workstreamId) {
3244
+ const tasks = core.listTasksSlim(ACTIVE_WORKSPACE_ID, {
3245
+ ...(workstreamId ? { workstreamId } : {}),
3246
+ includeArchived: true,
3247
+ });
3061
3248
  const tasksByWorkstreamId = new Map();
3062
3249
  for (const task of tasks) {
3063
3250
  const normalizedWorkstreamId = String(task.workstreamId || '').trim();
@@ -3090,6 +3277,34 @@ export function createTaskforceMcpServer(options = {}) {
3090
3277
  semantics: 'historical: active + archived',
3091
3278
  };
3092
3279
  }
3280
+ function emptyPlanningTaskCounts() {
3281
+ return buildPlanningTaskCounts([]);
3282
+ }
3283
+ function buildHistoricalTaskCountSnapshot(workstreamId) {
3284
+ return new Map(core.listTaskCountsByWorkstream(ACTIVE_WORKSPACE_ID, workstreamId).map((entry) => [
3285
+ entry.workstreamId,
3286
+ {
3287
+ total: entry.total,
3288
+ active: entry.active,
3289
+ archived: entry.archived,
3290
+ semantics: 'historical: active + archived',
3291
+ },
3292
+ ]));
3293
+ }
3294
+ function sumPlanningTaskCounts(workstreams, countsByWorkstreamId) {
3295
+ let total = 0;
3296
+ let active = 0;
3297
+ let archived = 0;
3298
+ for (const workstream of workstreams) {
3299
+ const counts = countsByWorkstreamId.get(workstream.id);
3300
+ if (!counts)
3301
+ continue;
3302
+ total += counts.total;
3303
+ active += counts.active;
3304
+ archived += counts.archived;
3305
+ }
3306
+ return { total, active, archived, semantics: 'historical: active + archived' };
3307
+ }
3093
3308
  function buildPlanningWorkstreamCounts(workstreams) {
3094
3309
  const active = workstreams.filter((workstream) => !workstream?.isArchived).length;
3095
3310
  const archived = workstreams.length - active;
@@ -3192,16 +3407,19 @@ export function createTaskforceMcpServer(options = {}) {
3192
3407
  };
3193
3408
  }
3194
3409
  function buildInitiativeResourcePayload(initiative, options) {
3195
- const tasksByWorkstreamId = options?.tasksByWorkstreamId || buildHistoricalWorkspaceTaskSnapshot().tasksByWorkstreamId;
3410
+ if (options?.detail === 'history') {
3411
+ return buildPlanningHistoryPayload('initiative', initiative, options.offset ?? 0, options.limit ?? 25);
3412
+ }
3196
3413
  const workstreams = options?.workstreamsByInitiativeId?.get(initiative.id)
3197
3414
  || core.listWorkstreams(ACTIVE_WORKSPACE_ID)
3198
3415
  .filter((workstream) => workstream.initiativeId === initiative.id);
3199
- const tasks = workstreams.flatMap((workstream) => tasksByWorkstreamId.get(workstream.id) || []);
3416
+ const taskCountsByWorkstreamId = options?.taskCountsByWorkstreamId || buildHistoricalTaskCountSnapshot();
3417
+ const taskCounts = sumPlanningTaskCounts(workstreams, taskCountsByWorkstreamId);
3200
3418
  if (options?.detail === 'compact') {
3201
3419
  return {
3202
3420
  initiative: buildPlanningInitiativeSummary(initiative),
3203
3421
  workstreamCount: workstreams.length,
3204
- taskCount: tasks.length,
3422
+ taskCount: taskCounts.total,
3205
3423
  nextDetailOption: 'full',
3206
3424
  };
3207
3425
  }
@@ -3215,13 +3433,12 @@ export function createTaskforceMcpServer(options = {}) {
3215
3433
  initiative: withPlanningEntityUrl('initiative', buildPlanningEntityMetadata('initiative', initiative)),
3216
3434
  attachments: listPlanningAttachmentDescriptors({ id: initiative.id, entity: initiative }),
3217
3435
  workstreamCounts: buildPlanningWorkstreamCounts(workstreams),
3218
- taskCounts: buildPlanningTaskCounts(tasks),
3436
+ taskCounts,
3219
3437
  workstreams: page.map((workstream) => {
3220
- const workstreamTasks = tasksByWorkstreamId.get(workstream.id) || [];
3221
3438
  return {
3222
3439
  workstream: buildPlanningWorkstreamSummary(workstream),
3223
3440
  attachments: listPlanningAttachmentDescriptors({ id: workstream.id, entity: workstream }),
3224
- taskCounts: buildPlanningTaskCounts(workstreamTasks),
3441
+ taskCounts: taskCountsByWorkstreamId.get(workstream.id) || emptyPlanningTaskCounts(),
3225
3442
  };
3226
3443
  }),
3227
3444
  pagination,
@@ -3237,27 +3454,38 @@ export function createTaskforceMcpServer(options = {}) {
3237
3454
  } : { truncated: false }),
3238
3455
  };
3239
3456
  }
3240
- if (options?.detail === 'history') {
3241
- return buildPlanningHistoryPayload('initiative', initiative, options.offset ?? 0, options.limit ?? 25);
3242
- }
3243
3457
  return {
3244
3458
  initiative: buildPlanningInitiativeDetail(initiative),
3245
3459
  workstreamCount: workstreams.length,
3246
- taskCount: tasks.length,
3460
+ taskCount: taskCounts.total,
3247
3461
  workstreams: workstreams.map((workstream) => buildPlanningWorkstreamSummary(workstream)),
3248
3462
  };
3249
3463
  }
3250
3464
  function buildWorkstreamResourcePayload(workstream, options) {
3251
- const tasksByWorkstreamId = options?.tasksByWorkstreamId || buildHistoricalWorkspaceTaskSnapshot().tasksByWorkstreamId;
3252
- const tasks = tasksByWorkstreamId.get(workstream.id)
3253
- || [];
3465
+ if (options?.detail === 'history') {
3466
+ return buildPlanningHistoryPayload('workstream', workstream, options.offset ?? 0, options.limit ?? 25);
3467
+ }
3254
3468
  const workstreamReference = typeof workstream.referenceLabel === 'string'
3255
3469
  ? workstream.referenceLabel
3256
3470
  : formatWorkstreamReference(workstream);
3471
+ if (options?.detail !== 'compact' && options?.detail !== 'planning') {
3472
+ const tasksByWorkstreamId = options?.tasksByWorkstreamId
3473
+ || buildHistoricalWorkspaceTaskSnapshot(workstream.id).tasksByWorkstreamId;
3474
+ const tasks = tasksByWorkstreamId.get(workstream.id) || [];
3475
+ return {
3476
+ workstream: buildPlanningWorkstreamDetail(workstream),
3477
+ taskCount: tasks.length,
3478
+ tasks,
3479
+ nextTaskTool: 'list_tasks',
3480
+ suggestedTaskArgs: { workstreamId: workstreamReference, format: 'json', limit: 50, offset: 0 },
3481
+ };
3482
+ }
3483
+ const taskCounts = (options?.taskCountsByWorkstreamId || buildHistoricalTaskCountSnapshot(workstream.id))
3484
+ .get(workstream.id) || emptyPlanningTaskCounts();
3257
3485
  if (options?.detail === 'compact') {
3258
3486
  return {
3259
3487
  workstream: buildPlanningWorkstreamSummary(workstream),
3260
- taskCount: tasks.length,
3488
+ taskCount: taskCounts.total,
3261
3489
  nextDetailOption: 'full',
3262
3490
  nextTaskTool: 'list_tasks',
3263
3491
  suggestedTaskArgs: { workstreamId: workstreamReference, format: 'json', limit: 50, offset: 0 },
@@ -3267,22 +3495,13 @@ export function createTaskforceMcpServer(options = {}) {
3267
3495
  return {
3268
3496
  workstream: withPlanningEntityUrl('workstream', buildPlanningEntityMetadata('workstream', workstream)),
3269
3497
  attachments: listPlanningAttachmentDescriptors({ id: workstream.id, entity: workstream }),
3270
- taskCounts: buildPlanningTaskCounts(tasks),
3498
+ taskCounts,
3271
3499
  nextTaskTool: 'list_tasks',
3272
3500
  suggestedTaskArgs: { workstreamId: workstreamReference, format: 'json', limit: 50, offset: 0 },
3273
3501
  guidance: 'taskCounts are historical (active + archived). list_tasks returns bounded active task rows; use search_tasks with scope=archive for archived task discovery.',
3274
3502
  };
3275
3503
  }
3276
- if (options?.detail === 'history') {
3277
- return buildPlanningHistoryPayload('workstream', workstream, options.offset ?? 0, options.limit ?? 25);
3278
- }
3279
- return {
3280
- workstream: buildPlanningWorkstreamDetail(workstream),
3281
- taskCount: tasks.length,
3282
- tasks,
3283
- nextTaskTool: 'list_tasks',
3284
- suggestedTaskArgs: { workstreamId: workstreamReference, format: 'json', limit: 50, offset: 0 },
3285
- };
3504
+ throw new Error(`Unhandled workstream detail: ${options?.detail}`);
3286
3505
  }
3287
3506
  function buildInitiativesResourcePayload(options) {
3288
3507
  const query = normalizePlanningSearchQuery(options?.query);
@@ -3296,12 +3515,12 @@ export function createTaskforceMcpServer(options = {}) {
3296
3515
  current.push(workstream);
3297
3516
  workstreamsByInitiativeId.set(normalizedInitiativeId, current);
3298
3517
  }
3299
- const { tasksByWorkstreamId } = buildHistoricalWorkspaceTaskSnapshot();
3518
+ const taskCountsByWorkstreamId = buildHistoricalTaskCountSnapshot();
3300
3519
  const initiatives = core.listInitiatives(ACTIVE_WORKSPACE_ID)
3301
3520
  .filter((initiative) => matchesInitiativePlanningQuery(initiative, query, workstreamsByInitiativeId.get(initiative.id) || []))
3302
3521
  .map((initiative) => buildInitiativeResourcePayload(initiative, {
3303
3522
  workstreamsByInitiativeId,
3304
- tasksByWorkstreamId,
3523
+ taskCountsByWorkstreamId,
3305
3524
  detail: 'compact',
3306
3525
  }));
3307
3526
  return {
@@ -3316,10 +3535,10 @@ export function createTaskforceMcpServer(options = {}) {
3316
3535
  for (const initiative of core.listInitiatives(ACTIVE_WORKSPACE_ID)) {
3317
3536
  initiativesById.set(initiative.id, initiative);
3318
3537
  }
3319
- const { tasksByWorkstreamId } = buildHistoricalWorkspaceTaskSnapshot();
3538
+ const taskCountsByWorkstreamId = buildHistoricalTaskCountSnapshot();
3320
3539
  const workstreams = core.listWorkstreams(ACTIVE_WORKSPACE_ID)
3321
3540
  .filter((workstream) => matchesWorkstreamPlanningQuery(workstream, query, initiativesById.get(workstream.initiativeId || '')))
3322
- .map((workstream) => buildWorkstreamResourcePayload(workstream, { tasksByWorkstreamId, detail: 'compact' }));
3541
+ .map((workstream) => buildWorkstreamResourcePayload(workstream, { taskCountsByWorkstreamId, detail: 'compact' }));
3323
3542
  return {
3324
3543
  total: workstreams.length,
3325
3544
  ...(query ? { query } : {}),
@@ -3631,6 +3850,7 @@ export function createTaskforceMcpServer(options = {}) {
3631
3850
  'add_comment',
3632
3851
  'add_document_review_comment',
3633
3852
  'add_initiative_comment',
3853
+ 'add_task_checklist_item',
3634
3854
  'add_workstream_comment',
3635
3855
  'bulk_add_annotated_attachment_markers',
3636
3856
  'create_annotated_attachment_session',
@@ -3639,6 +3859,7 @@ export function createTaskforceMcpServer(options = {}) {
3639
3859
  'create_task',
3640
3860
  'create_workstream',
3641
3861
  'link_document',
3862
+ 'link_task_relationship',
3642
3863
  'rehome_document',
3643
3864
  'link_task_context',
3644
3865
  'resolve_profile',
@@ -3662,7 +3883,7 @@ export function createTaskforceMcpServer(options = {}) {
3662
3883
  get assigneeHint() {
3663
3884
  return describeEligibleAssignees();
3664
3885
  },
3665
- describeTool: (text) => `[Project: ${PROJECT_NAME}] ${text}`,
3886
+ describeTool: (text) => text,
3666
3887
  };
3667
3888
  };
3668
3889
  const titleForTool = (name) => String(name || '')
@@ -4529,6 +4750,7 @@ export function createTaskforceMcpServer(options = {}) {
4529
4750
  uploadDraftId: result.uploadDraftId,
4530
4751
  mimeType: result.mimeType,
4531
4752
  uploadedPath: result.uploadedPath,
4753
+ ...(result.displayName ? { displayName: result.displayName } : {}),
4532
4754
  ...(result.caption ? { caption: result.caption } : {}),
4533
4755
  ...(result.overwrite ? { overwrite: true } : {}),
4534
4756
  ...(result.expectedSizeBytes !== undefined ? { expectedSizeBytes: result.expectedSizeBytes } : {}),
@@ -4827,8 +5049,12 @@ export function createTaskforceMcpServer(options = {}) {
4827
5049
  };
4828
5050
  }
4829
5051
  case "resolve_profile": {
4830
- const { name: profileName, icon, color, description, role, provider, model, surfaceType, profileToken } = args;
5052
+ const { name: profileName, icon, color, description, role, provider, model, surfaceType, profileId, profileToken } = args;
5053
+ if (aiProfileIdentityConflictDetected) {
5054
+ throw new TaskforceRuleError('This MCP session is quarantined after a profile identity conflict. Reconnect with the intended profile before retrying.', 409, 'AI_PROFILE_BINDING_CONFLICT', { reason: 'session_identity_quarantined', nextTool: 'resolve_profile' });
5055
+ }
4831
5056
  const suppliedProfileToken = typeof profileToken === 'string' ? profileToken.trim() : '';
5057
+ const selectedProfileId = typeof profileId === 'string' ? profileId.trim() : '';
4832
5058
  const sessionBoundProfile = activeAiProfileId
4833
5059
  ? core.listAiProfiles(ACTIVE_WORKSPACE_ID, { includeArchived: true })
4834
5060
  .find((profile) => profile.id === activeAiProfileId) || null
@@ -4841,9 +5067,26 @@ export function createTaskforceMcpServer(options = {}) {
4841
5067
  repairConnection: false,
4842
5068
  });
4843
5069
  if (sessionBoundProfile && trustedBoundProfile && sessionBoundProfile.id !== trustedBoundProfile.id) {
4844
- throw new TaskforceRuleError('The MCP session and authenticated connection are bound to different AI profiles. Reconnect before retrying.', 409, 'AI_PROFILE_BINDING_CONFLICT', { sessionProfileId: sessionBoundProfile.id, trustedProfileId: trustedBoundProfile.id });
5070
+ failAiProfileBindingConflict('The MCP session and authenticated connection are bound to different AI profiles. Reconnect before retrying.', { reason: 'session_connection_mismatch', sessionProfileId: sessionBoundProfile.id, trustedProfileId: trustedBoundProfile.id });
5071
+ }
5072
+ const selectableProfiles = listSelectableOAuthProfiles(profileName);
5073
+ const explicitlySelectedProfile = selectedProfileId
5074
+ ? selectableProfiles.find((profile) => profile.id === selectedProfileId) || null
5075
+ : null;
5076
+ if (selectedProfileId && !explicitlySelectedProfile) {
5077
+ throw new TaskforceRuleError('The selected AI profile is not an active compatible identity for this OAuth client and requested name.', 409, 'AI_PROFILE_SELECTION_INVALID', { selectedProfileId });
5078
+ }
5079
+ const alreadyBoundProfile = sessionBoundProfile || trustedBoundProfile;
5080
+ if (alreadyBoundProfile) {
5081
+ assertRequestedIdentityMatchesBoundProfile(alreadyBoundProfile, {
5082
+ name: profileName,
5083
+ provider,
5084
+ });
4845
5085
  }
4846
- const boundProfile = sessionBoundProfile || trustedBoundProfile;
5086
+ if (alreadyBoundProfile && explicitlySelectedProfile && alreadyBoundProfile.id !== explicitlySelectedProfile.id) {
5087
+ failAiProfileBindingConflict('The selected AI profile conflicts with the profile already bound to this MCP session or authenticated connection.', { reason: 'selected_profile_mismatch', boundProfileId: alreadyBoundProfile.id, selectedProfileId: explicitlySelectedProfile.id });
5088
+ }
5089
+ const boundProfile = alreadyBoundProfile || explicitlySelectedProfile;
4847
5090
  if (boundProfile && suppliedProfileToken) {
4848
5091
  const tokenProfile = core.getAiProfileByToken({
4849
5092
  workspaceId: ACTIVE_WORKSPACE_ID,
@@ -4851,7 +5094,7 @@ export function createTaskforceMcpServer(options = {}) {
4851
5094
  seatScope: configuredAiProfileSeatScope,
4852
5095
  });
4853
5096
  if (!tokenProfile || tokenProfile.id !== boundProfile.id) {
4854
- throw new TaskforceRuleError('The supplied profile token conflicts with the AI profile already bound to this MCP session or authenticated connection.', 409, 'AI_PROFILE_BINDING_CONFLICT', { boundProfileId: boundProfile.id, ...(tokenProfile ? { suppliedProfileId: tokenProfile.id } : {}) });
5097
+ failAiProfileBindingConflict('The supplied profile token conflicts with the AI profile already bound to this MCP session or authenticated connection.', { reason: 'bound_token_mismatch', boundProfileId: boundProfile.id, ...(tokenProfile ? { suppliedProfileId: tokenProfile.id } : {}) });
4855
5098
  }
4856
5099
  }
4857
5100
  if (!boundProfile && !suppliedProfileToken && auditActorType === 'oauth') {
@@ -4860,7 +5103,16 @@ export function createTaskforceMcpServer(options = {}) {
4860
5103
  const hasIdentityCandidate = core.listAiProfiles(ACTIVE_WORKSPACE_ID).some((profile) => ((profile.seatScope ?? null) === configuredAiProfileSeatScope
4861
5104
  && (profile.name.trim().toLowerCase() === requestedName || profile.username.trim().toLowerCase() === requestedUsername)));
4862
5105
  if (hasIdentityCandidate) {
4863
- throw new TaskforceRuleError('An AI profile with this identity already exists, but it is not bound to this OAuth client. Select the intended profile in Taskforce before retrying.', 409, 'AI_PROFILE_SELECTION_REQUIRED');
5106
+ const candidates = selectableProfiles.map((profile) => buildAiProfileDescriptor(profile));
5107
+ const suggestedArgs = candidates.length === 1
5108
+ ? { name: profileName, profileId: candidates[0].id }
5109
+ : undefined;
5110
+ throw new TaskforceRuleError(candidates.length > 0
5111
+ ? 'An AI profile with this identity already exists but is not bound to this OAuth client. Select a compatible candidate explicitly and retry resolve_profile.'
5112
+ : 'An AI profile with this identity already exists but is not compatible with this OAuth client. Select the intended profile in Taskforce settings before retrying.', 409, 'AI_PROFILE_SELECTION_REQUIRED', {
5113
+ candidates,
5114
+ ...(suggestedArgs ? { suggestedArgs } : {}),
5115
+ });
4864
5116
  }
4865
5117
  }
4866
5118
  const configuredClientEntry = getMcpClientRegistryEntry(configuredMcpClientId);
@@ -5040,7 +5292,7 @@ export function createTaskforceMcpServer(options = {}) {
5040
5292
  }
5041
5293
  case "search_tasks": {
5042
5294
  const { query, scope = "all" } = args;
5043
- const results = core.searchTasks(query, scope, ACTIVE_WORKSPACE_ID);
5295
+ const results = core.searchTaskSummaries(query, scope, ACTIVE_WORKSPACE_ID);
5044
5296
  return {
5045
5297
  content: [{ type: "text", text: renderTaskTable(results) }],
5046
5298
  };
@@ -5200,9 +5452,11 @@ export function createTaskforceMcpServer(options = {}) {
5200
5452
  case "list_task_relationships": {
5201
5453
  const { taskId, type, direction = 'both' } = args;
5202
5454
  const { id } = resolveTaskByIdentifierOrThrow(taskId);
5203
- const relationships = core.listTaskRelationships(id, ACTIVE_WORKSPACE_ID, { type, direction }).map((relationship) => {
5204
- const source = core.getTask(relationship.sourceTaskId, ACTIVE_WORKSPACE_ID);
5205
- const target = core.getTask(relationship.targetTaskId, ACTIVE_WORKSPACE_ID);
5455
+ const relationshipRows = core.listTaskRelationships(id, ACTIVE_WORKSPACE_ID, { type, direction });
5456
+ const taskSummariesById = new Map(core.listTaskReferenceSummaries(relationshipRows.flatMap((relationship) => [relationship.sourceTaskId, relationship.targetTaskId]), ACTIVE_WORKSPACE_ID).map((task) => [task.id, task]));
5457
+ const relationships = relationshipRows.map((relationship) => {
5458
+ const source = taskSummariesById.get(relationship.sourceTaskId) || null;
5459
+ const target = taskSummariesById.get(relationship.targetTaskId) || null;
5206
5460
  const perspectiveType = relationship.kind === 'dependency'
5207
5461
  ? (relationship.sourceTaskId === id ? 'depends_on' : 'blocks')
5208
5462
  : relationship.kind === 'related' ? 'related_to' : 'supersedes';
@@ -5367,12 +5621,21 @@ export function createTaskforceMcpServer(options = {}) {
5367
5621
  return { content: [{ type: 'text', text: JSON.stringify(linkedResult, null, 2) }] };
5368
5622
  }
5369
5623
  case "create_initiative": {
5370
- const router = getDurableInitiativeMutationRouter();
5624
+ const { idempotencyKey: rawIdempotencyKey, ...initiativeArgs } = args;
5625
+ const idempotencyKey = normalizeExplicitIdempotencyKey(rawIdempotencyKey);
5626
+ const operationId = idempotencyKey
5627
+ ? stableExplicitMutationId('operation', 'create_initiative', idempotencyKey)
5628
+ : undefined;
5629
+ const router = getDurableInitiativeMutationRouter(operationId ? { operationId } : undefined);
5630
+ if (idempotencyKey)
5631
+ requireDurableExplicitIdempotency(router.enabled, 'initiative');
5371
5632
  let prepared;
5372
5633
  try {
5373
- prepared = planningCommandPreparationService.prepareInitiativeCreate(args, {
5634
+ prepared = planningCommandPreparationService.prepareInitiativeCreate(initiativeArgs, {
5374
5635
  durable: router.enabled,
5375
- entityId: requireStableMcpRequestId('initiative', 'create'),
5636
+ entityId: idempotencyKey
5637
+ ? stableExplicitMutationId('initiative', 'create_initiative', idempotencyKey)
5638
+ : requireStableMcpRequestId('initiative', 'create'),
5376
5639
  createdAt: new Date().toISOString(),
5377
5640
  });
5378
5641
  }
@@ -5421,14 +5684,23 @@ export function createTaskforceMcpServer(options = {}) {
5421
5684
  };
5422
5685
  }
5423
5686
  case "add_initiative_comment": {
5424
- const { id, text } = args;
5687
+ const { id, text, idempotencyKey: rawIdempotencyKey } = args;
5425
5688
  const { id: initiativeId } = resolveInitiativeByIdentifierOrThrow(id);
5426
5689
  const validation = validateStructuredCommentForAdd(text);
5427
5690
  if (!validation.ok) {
5428
5691
  throw new McpPublicError(validation.message);
5429
5692
  }
5430
5693
  const effectiveAuthor = getActiveMcpActorRef();
5431
- const router = getDurableInitiativeMutationRouter();
5694
+ const idempotencyKey = normalizeExplicitIdempotencyKey(rawIdempotencyKey);
5695
+ const explicitCommentId = idempotencyKey
5696
+ ? stableExplicitMutationId('comment', 'add_initiative_comment', idempotencyKey)
5697
+ : null;
5698
+ const router = getDurableInitiativeMutationRouter(idempotencyKey ? {
5699
+ operationId: stableExplicitMutationId('operation', 'add_initiative_comment', idempotencyKey),
5700
+ commentId: explicitCommentId,
5701
+ } : undefined);
5702
+ if (idempotencyKey)
5703
+ requireDurableExplicitIdempotency(router.enabled, 'initiative comment');
5432
5704
  const updated = router.enabled
5433
5705
  ? initiativeFromDurableResult(router.appendComment(initiativeId, {
5434
5706
  author: effectiveAuthor,
@@ -5440,7 +5712,11 @@ export function createTaskforceMcpServer(options = {}) {
5440
5712
  if (!updated) {
5441
5713
  throw new McpPublicError(buildInitiativeLookupFailureMessage(String(id || '').trim()), 'MCP_NOT_FOUND', 404);
5442
5714
  }
5443
- const persistedComment = Array.isArray(updated.comments) ? updated.comments.at(-1) : null;
5715
+ const persistedComment = Array.isArray(updated.comments)
5716
+ ? (explicitCommentId
5717
+ ? updated.comments.find((candidate) => candidate.id === explicitCommentId) || null
5718
+ : updated.comments.at(-1) || null)
5719
+ : null;
5444
5720
  recordMcpWriteAudit('add_initiative_comment', {
5445
5721
  entityType: 'initiative',
5446
5722
  initiativeId: updated.id,
@@ -5454,7 +5730,7 @@ export function createTaskforceMcpServer(options = {}) {
5454
5730
  return {
5455
5731
  content: [{ type: "text", text: JSON.stringify(buildPlanningWriteAcknowledgement('initiative', updated, 'add_initiative_comment', {
5456
5732
  changedFields: ['comments'],
5457
- comment: Array.isArray(updated.comments) ? updated.comments.at(-1) : null,
5733
+ comment: persistedComment,
5458
5734
  }), null, 2) }],
5459
5735
  };
5460
5736
  }
@@ -5515,12 +5791,21 @@ export function createTaskforceMcpServer(options = {}) {
5515
5791
  };
5516
5792
  }
5517
5793
  case "create_workstream": {
5518
- const router = getDurableWorkstreamMutationRouter();
5794
+ const { idempotencyKey: rawIdempotencyKey, ...workstreamArgs } = args;
5795
+ const idempotencyKey = normalizeExplicitIdempotencyKey(rawIdempotencyKey);
5796
+ const operationId = idempotencyKey
5797
+ ? stableExplicitMutationId('operation', 'create_workstream', idempotencyKey)
5798
+ : undefined;
5799
+ const router = getDurableWorkstreamMutationRouter(operationId ? { operationId } : undefined);
5800
+ if (idempotencyKey)
5801
+ requireDurableExplicitIdempotency(router.enabled, 'workstream');
5519
5802
  let prepared;
5520
5803
  try {
5521
- prepared = planningCommandPreparationService.prepareWorkstreamCreate(args, {
5804
+ prepared = planningCommandPreparationService.prepareWorkstreamCreate(workstreamArgs, {
5522
5805
  durable: router.enabled,
5523
- entityId: requireStableMcpRequestId('workstream', 'create'),
5806
+ entityId: idempotencyKey
5807
+ ? stableExplicitMutationId('workstream', 'create_workstream', idempotencyKey)
5808
+ : requireStableMcpRequestId('workstream', 'create'),
5524
5809
  createdAt: new Date().toISOString(),
5525
5810
  });
5526
5811
  }
@@ -5576,43 +5861,64 @@ export function createTaskforceMcpServer(options = {}) {
5576
5861
  };
5577
5862
  }
5578
5863
  case "add_workstream_comment": {
5579
- const { id, text } = args;
5580
- const { id: workstreamId } = resolveWorkstreamByIdentifierOrThrow(id);
5581
- const validation = validateStructuredCommentForAdd(text);
5582
- if (!validation.ok) {
5583
- throw new McpPublicError(validation.message);
5584
- }
5585
- const effectiveAuthor = getActiveMcpActorRef();
5586
- const router = getDurableWorkstreamMutationRouter();
5587
- const updated = router.enabled
5588
- ? workstreamFromDurableResult(router.appendComment(workstreamId, {
5589
- author: effectiveAuthor,
5590
- submittedAuthor: effectiveAuthor,
5591
- text,
5592
- timestamp: new Date().toISOString(),
5593
- }))
5594
- : core.addWorkstreamComment(workstreamId, text, effectiveAuthor, ACTIVE_WORKSPACE_ID, { actorRef: effectiveAuthor });
5864
+ const { id, text, idempotencyKey: rawIdempotencyKey } = args;
5865
+ const { id: workstreamId } = measureToolSubphase('lookup', () => resolveWorkstreamByIdentifierOrThrow(id));
5866
+ const { effectiveAuthor, router, comment, explicitCommentId } = measureToolSubphase('prepare', () => {
5867
+ const validation = validateStructuredCommentForAdd(text);
5868
+ if (!validation.ok) {
5869
+ throw new McpPublicError(validation.message);
5870
+ }
5871
+ const effectiveAuthor = getActiveMcpActorRef();
5872
+ const idempotencyKey = normalizeExplicitIdempotencyKey(rawIdempotencyKey);
5873
+ const explicitCommentId = idempotencyKey
5874
+ ? stableExplicitMutationId('comment', 'add_workstream_comment', idempotencyKey)
5875
+ : null;
5876
+ const router = getDurableWorkstreamMutationRouter(idempotencyKey ? {
5877
+ operationId: stableExplicitMutationId('operation', 'add_workstream_comment', idempotencyKey),
5878
+ commentId: explicitCommentId,
5879
+ } : undefined);
5880
+ if (idempotencyKey)
5881
+ requireDurableExplicitIdempotency(router.enabled, 'workstream comment');
5882
+ return {
5883
+ effectiveAuthor,
5884
+ router,
5885
+ explicitCommentId,
5886
+ comment: {
5887
+ author: effectiveAuthor,
5888
+ submittedAuthor: effectiveAuthor,
5889
+ text,
5890
+ timestamp: new Date().toISOString(),
5891
+ },
5892
+ };
5893
+ });
5894
+ const updated = measureToolSubphase('durable-write', () => router.enabled
5895
+ ? workstreamFromDurableResult(router.appendComment(workstreamId, comment))
5896
+ : core.addWorkstreamComment(workstreamId, text, effectiveAuthor, ACTIVE_WORKSPACE_ID, { actorRef: effectiveAuthor }));
5595
5897
  if (!updated) {
5596
5898
  throw new McpPublicError(buildWorkstreamLookupFailureMessage(String(id || '').trim()), 'MCP_NOT_FOUND', 404);
5597
5899
  }
5598
- const persistedComment = Array.isArray(updated.comments) ? updated.comments.at(-1) : null;
5599
- recordMcpWriteAudit('add_workstream_comment', {
5900
+ const persistedComment = Array.isArray(updated.comments)
5901
+ ? (explicitCommentId
5902
+ ? updated.comments.find((candidate) => candidate.id === explicitCommentId) || null
5903
+ : updated.comments.at(-1) || null)
5904
+ : null;
5905
+ measureToolSubphase('audit', () => recordMcpWriteAudit('add_workstream_comment', {
5600
5906
  entityType: 'workstream',
5601
5907
  workstreamId: updated.id,
5602
5908
  commentId: persistedComment?.id || null,
5603
5909
  contentActorId: effectiveAuthor,
5604
- });
5605
- await notifyResourceMutation({
5910
+ }));
5911
+ await measureToolSubphaseAsync('notify', () => notifyResourceMutation({
5606
5912
  includeWorkstreams: true,
5607
5913
  workstreamIds: [updated.id],
5608
5914
  ...(updated.initiativeId ? { includeInitiatives: true, initiativeIds: [updated.initiativeId] } : {}),
5609
- });
5610
- return {
5915
+ }));
5916
+ return measureToolSubphase('serialize', () => ({
5611
5917
  content: [{ type: "text", text: JSON.stringify(buildPlanningWriteAcknowledgement('workstream', updated, 'add_workstream_comment', {
5612
5918
  changedFields: ['comments'],
5613
- comment: Array.isArray(updated.comments) ? updated.comments.at(-1) : null,
5919
+ comment: persistedComment,
5614
5920
  }), null, 2) }],
5615
- };
5921
+ }));
5616
5922
  }
5617
5923
  case "archive_workstream": {
5618
5924
  const { id } = args;
@@ -5655,34 +5961,54 @@ export function createTaskforceMcpServer(options = {}) {
5655
5961
  case "update_task_workstream": {
5656
5962
  const { id: legacyInputId, taskId, workstreamId } = args;
5657
5963
  const inputId = taskId ?? legacyInputId;
5658
- const { task } = resolveTaskByIdentifierOrThrow(inputId);
5659
- let prepared;
5660
- try {
5661
- prepared = planningCommandPreparationService.prepareTaskWorkstreamPlacement(task, workstreamId);
5662
- }
5663
- catch (error) {
5664
- rethrowPlanningPreparationError(error);
5964
+ const { task } = measureToolSubphase('lookup', () => resolveTaskByIdentifierOrThrow(inputId));
5965
+ const prepared = measureToolSubphase('prepare', () => {
5966
+ try {
5967
+ return planningCommandPreparationService.prepareTaskWorkstreamPlacement(task, workstreamId);
5968
+ }
5969
+ catch (error) {
5970
+ return rethrowPlanningPreparationError(error);
5971
+ }
5972
+ });
5973
+ if (prepared.nextWorkstreamId === prepared.previousWorkstreamId) {
5974
+ measureToolSubphase('audit', () => recordMcpWriteAudit('update_task_workstream', {
5975
+ taskId: task.id,
5976
+ workstreamId: prepared.nextWorkstreamId,
5977
+ previousWorkstreamId: prepared.previousWorkstreamId,
5978
+ noOp: true,
5979
+ }));
5980
+ return measureToolSubphase('serialize', () => ({
5981
+ content: [{
5982
+ type: "text",
5983
+ text: JSON.stringify({
5984
+ task: buildTaskWriteSummary(task),
5985
+ workstreamId: prepared.nextWorkstreamId,
5986
+ previousWorkstreamId: prepared.previousWorkstreamId,
5987
+ noOp: true,
5988
+ }, null, 2)
5989
+ }],
5990
+ }));
5665
5991
  }
5666
- const updated = mutateTaskThroughStatusBoundary({
5992
+ const updated = measureToolSubphase('durable-write', () => mutateTaskThroughStatusBoundary({
5667
5993
  task,
5668
5994
  updates: { workstreamId: prepared.nextWorkstreamId },
5669
- });
5995
+ }));
5670
5996
  if (!updated) {
5671
5997
  throw new McpPublicError(buildTaskLookupFailureMessage(String(inputId || '').trim()), 'MCP_NOT_FOUND', 404);
5672
5998
  }
5673
- recordMcpWriteAudit('update_task_workstream', {
5999
+ measureToolSubphase('audit', () => recordMcpWriteAudit('update_task_workstream', {
5674
6000
  taskId: updated.id,
5675
6001
  workstreamId: prepared.nextWorkstreamId,
5676
6002
  previousWorkstreamId: prepared.previousWorkstreamId,
5677
- });
5678
- await notifyResourceMutation({
6003
+ }));
6004
+ await measureToolSubphaseAsync('notify', () => notifyResourceMutation({
5679
6005
  includeTasks: true,
5680
6006
  taskIds: [updated.id],
5681
6007
  ...(prepared.affectedWorkstreamIds.length > 0
5682
6008
  ? { includeWorkstreams: true, workstreamIds: prepared.affectedWorkstreamIds }
5683
6009
  : {}),
5684
- });
5685
- return {
6010
+ }));
6011
+ return measureToolSubphase('serialize', () => ({
5686
6012
  content: [{
5687
6013
  type: "text",
5688
6014
  text: JSON.stringify({
@@ -5691,7 +6017,7 @@ export function createTaskforceMcpServer(options = {}) {
5691
6017
  previousWorkstreamId: prepared.previousWorkstreamId,
5692
6018
  }, null, 2)
5693
6019
  }],
5694
- };
6020
+ }));
5695
6021
  }
5696
6022
  case "update_workstream_initiative": {
5697
6023
  const { id: legacyInputId, workstreamId, initiativeId } = args;
@@ -6585,7 +6911,7 @@ export function createTaskforceMcpServer(options = {}) {
6585
6911
  };
6586
6912
  }
6587
6913
  case "add_document_review_comment": {
6588
- const { sessionId, body, anchor, parentCommentId } = args;
6914
+ const { sessionId, body, anchor, parentCommentId, idempotencyKey: rawIdempotencyKey } = args;
6589
6915
  if (!sessionId || typeof sessionId !== 'string') {
6590
6916
  throw new McpPublicError('sessionId is required');
6591
6917
  }
@@ -6596,6 +6922,10 @@ export function createTaskforceMcpServer(options = {}) {
6596
6922
  && Object.prototype.hasOwnProperty.call(args, 'anchor') && anchor !== null && anchor !== undefined) {
6597
6923
  throw new McpPublicError('Replies cannot have anchors');
6598
6924
  }
6925
+ const idempotencyKey = normalizeExplicitIdempotencyKey(rawIdempotencyKey);
6926
+ const idempotentCommentId = idempotencyKey
6927
+ ? stableExplicitMutationId('comment', 'add_document_review_comment', idempotencyKey)
6928
+ : null;
6599
6929
  const session = typeof parentCommentId === 'string' && parentCommentId.trim()
6600
6930
  ? core.addDocumentReviewReply({
6601
6931
  workspaceId: ACTIVE_WORKSPACE_ID,
@@ -6603,6 +6933,8 @@ export function createTaskforceMcpServer(options = {}) {
6603
6933
  parentCommentId: parentCommentId.trim(),
6604
6934
  body,
6605
6935
  actorId: getActiveMcpActorRef(),
6936
+ idempotencyKey,
6937
+ commentId: idempotentCommentId,
6606
6938
  })
6607
6939
  : core.addDocumentReviewComment({
6608
6940
  workspaceId: ACTIVE_WORKSPACE_ID,
@@ -6610,9 +6942,13 @@ export function createTaskforceMcpServer(options = {}) {
6610
6942
  body,
6611
6943
  anchor: anchor ?? null,
6612
6944
  actorId: getActiveMcpActorRef(),
6945
+ idempotencyKey,
6946
+ commentId: idempotentCommentId,
6613
6947
  });
6614
6948
  const asset = workspaceAssetStore?.getDocument(session.assetId, ACTIVE_WORKSPACE_ID) || null;
6615
- const comment = session.comments.at(-1) || null;
6949
+ const comment = idempotentCommentId
6950
+ ? session.comments.find((candidate) => candidate.id === idempotentCommentId) || null
6951
+ : session.comments.at(-1) || null;
6616
6952
  recordMcpWriteAudit('add_document_review_comment', {
6617
6953
  sessionId: session.id,
6618
6954
  documentAssetId: session.assetId,
@@ -7150,17 +7486,24 @@ export function createTaskforceMcpServer(options = {}) {
7150
7486
  };
7151
7487
  }
7152
7488
  case "add_comment": {
7153
- const { id: inputId, text } = args;
7489
+ const { id: inputId, text, idempotencyKey: rawIdempotencyKey } = args;
7154
7490
  const { id, task } = resolveTaskByIdentifierOrThrow(inputId);
7155
7491
  const validation = validateStructuredCommentForAdd(text);
7156
7492
  if (!validation.ok) {
7157
7493
  throw new McpPublicError(validation.message);
7158
7494
  }
7159
7495
  const effectiveAuthor = getActiveMcpActorRef();
7160
- const operationId = requireStableMcpRequestId('operation', `task-comment:${id}`);
7161
- const commentId = requireStableMcpRequestId('comment', id);
7496
+ const idempotencyKey = normalizeExplicitIdempotencyKey(rawIdempotencyKey);
7497
+ const operationId = idempotencyKey
7498
+ ? stableExplicitMutationId('operation', 'add_comment', idempotencyKey)
7499
+ : requireStableMcpRequestId('operation', `task-comment:${id}`);
7500
+ const commentId = idempotencyKey
7501
+ ? stableExplicitMutationId('comment', 'add_comment', idempotencyKey)
7502
+ : requireStableMcpRequestId('comment', id);
7162
7503
  const existingCommentIds = new Set((task.comments || []).map((comment) => comment.id));
7163
7504
  const durableRouter = getDurableTaskCommentMutationRouter(effectiveAuthor, operationId);
7505
+ if (idempotencyKey)
7506
+ requireDurableExplicitIdempotency(durableRouter.enabled, 'task comment');
7164
7507
  const updated = durableRouter.enabled
7165
7508
  ? durableRouter.append({
7166
7509
  taskId: id,
@@ -7558,61 +7901,113 @@ export function createTaskforceMcpServer(options = {}) {
7558
7901
  const mcpInputValidator = createMcpInputValidator();
7559
7902
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
7560
7903
  const { name, arguments: args } = resolveMcpToolCallCompatibility(request.params.name, request.params.arguments);
7904
+ const toolStartedAtMs = Date.now();
7905
+ let preparationMs = 0;
7906
+ let validationMs = 0;
7907
+ let handlerMs = 0;
7908
+ let responseBytes = 0;
7909
+ let timingOutcome = 'success';
7910
+ const subphases = {};
7561
7911
  try {
7562
- assertToolProfile(name);
7563
- assertToolPermission(name);
7564
- const handler = toolHandlers.get(name);
7565
- if (!handler) {
7566
- throw new Error(`Unknown tool: ${name}`);
7912
+ const preparationStartedAtMs = Date.now();
7913
+ let handler;
7914
+ try {
7915
+ assertToolProfile(name);
7916
+ assertToolPermission(name);
7917
+ handler = toolHandlers.get(name);
7918
+ if (!handler) {
7919
+ throw new Error(`Unknown tool: ${name}`);
7920
+ }
7921
+ ensureResolvedProfileForTool(name, args);
7922
+ assertResolvedProfileForMutatingTool(name);
7923
+ }
7924
+ finally {
7925
+ preparationMs = Date.now() - preparationStartedAtMs;
7567
7926
  }
7568
- ensureResolvedProfileForTool(name, args);
7569
- assertResolvedProfileForMutatingTool(name);
7570
7927
  const execute = () => executeMcpToolWithReadRetry(name, (args || {}), async (validatedArgs) => {
7928
+ const validationStartedAtMs = Date.now();
7571
7929
  const descriptor = getCurrentToolDescriptor(name);
7572
7930
  if (!descriptor)
7573
7931
  throw new Error(`Unknown tool: ${name}`);
7574
7932
  const validation = mcpInputValidator.validate(name, descriptor.inputSchema, validatedArgs);
7933
+ validationMs += Date.now() - validationStartedAtMs;
7575
7934
  if (!validation.valid) {
7576
7935
  throw new McpPublicError(`Arguments for ${name} do not match the declared input schema.`, 'MCP_INVALID_ARGUMENT', 400, { issues: validation.issues });
7577
7936
  }
7578
- return await handler(stripMcpEnvelopeArgsForHandler(name, validatedArgs));
7937
+ const handlerStartedAtMs = Date.now();
7938
+ try {
7939
+ return await mcpToolSubphaseTimings.run(subphases, () => handler(stripMcpEnvelopeArgsForHandler(name, validatedArgs)));
7940
+ }
7941
+ finally {
7942
+ handlerMs += Date.now() - handlerStartedAtMs;
7943
+ }
7579
7944
  });
7580
- if (extra.requestId === undefined || extra.requestId === null)
7581
- return await execute();
7582
- const transportSessionId = String(extra.sessionId || '').trim();
7583
- const requestIdentity = operationIdentityScope
7584
- ? fingerprintWorkspaceSyncPayload({
7585
- kind: 'shared-mcp-request',
7586
- operationIdentityScope,
7587
- transportSessionId: transportSessionId
7588
- || options.auth?.mcpConnectionId
7589
- || options.auth?.mcpClientId
7590
- || 'mcp',
7591
- requestId: { type: typeof extra.requestId, value: extra.requestId },
7592
- tool: name,
7593
- })
7594
- : [
7595
- ...(runtimeMode === 'local' || !transportSessionId ? [runtimeRequestNamespace] : []),
7945
+ let result;
7946
+ if (extra.requestId === undefined || extra.requestId === null) {
7947
+ result = await execute();
7948
+ }
7949
+ else {
7950
+ const transportSessionId = String(extra.sessionId || '').trim();
7951
+ const requestIdentity = operationIdentityScope
7952
+ ? fingerprintWorkspaceSyncPayload({
7953
+ kind: 'shared-mcp-request',
7954
+ operationIdentityScope,
7955
+ transportSessionId: transportSessionId
7956
+ || options.auth?.mcpConnectionId
7957
+ || options.auth?.mcpClientId
7958
+ || 'mcp',
7959
+ requestId: { type: typeof extra.requestId, value: extra.requestId },
7960
+ tool: name,
7961
+ })
7962
+ : [
7963
+ ...(runtimeMode === 'local' || !transportSessionId ? [runtimeRequestNamespace] : []),
7964
+ String(transportSessionId || options.auth?.mcpConnectionId || options.auth?.mcpClientId || 'mcp'),
7965
+ String(extra.requestId),
7966
+ name,
7967
+ ].join(':');
7968
+ const logicalCloudCreateIdentity = [
7596
7969
  String(transportSessionId || options.auth?.mcpConnectionId || options.auth?.mcpClientId || 'mcp'),
7597
7970
  String(extra.requestId),
7598
7971
  name,
7599
7972
  ].join(':');
7600
- const logicalCloudCreateIdentity = [
7601
- String(transportSessionId || options.auth?.mcpConnectionId || options.auth?.mcpClientId || 'mcp'),
7602
- String(extra.requestId),
7603
- name,
7604
- ].join(':');
7605
- return await cloudCreateRequestIdentity.run(logicalCloudCreateIdentity, () => mcpRequestIdentity.run(requestIdentity, execute));
7973
+ result = await cloudCreateRequestIdentity.run(logicalCloudCreateIdentity, () => mcpRequestIdentity.run(requestIdentity, execute));
7974
+ }
7975
+ responseBytes = measureToolResultBytes(result);
7976
+ return result;
7606
7977
  }
7607
7978
  catch (error) {
7979
+ timingOutcome = 'failure';
7608
7980
  const recoverable = buildRecoverableToolErrorResult(error, auditRequestId);
7609
7981
  if (recoverable) {
7610
- return {
7982
+ const result = {
7611
7983
  ...recoverable,
7612
7984
  isError: true,
7613
7985
  };
7986
+ responseBytes = measureToolResultBytes(result);
7987
+ return result;
7988
+ }
7989
+ const result = buildUnexpectedToolErrorResult(auditRequestId || extra.requestId);
7990
+ responseBytes = measureToolResultBytes(result);
7991
+ return result;
7992
+ }
7993
+ finally {
7994
+ try {
7995
+ options.onToolCallTiming?.({
7996
+ requestId: String(auditRequestId || extra.requestId || '').trim() || null,
7997
+ toolName: name,
7998
+ isWrite: isMcpWriteToolName(name),
7999
+ outcome: timingOutcome,
8000
+ totalMs: Date.now() - toolStartedAtMs,
8001
+ preparationMs,
8002
+ validationMs,
8003
+ handlerMs,
8004
+ responseBytes,
8005
+ subphases: { ...subphases },
8006
+ });
8007
+ }
8008
+ catch {
8009
+ // Observability must never change tool behavior.
7614
8010
  }
7615
- return buildUnexpectedToolErrorResult(auditRequestId || extra.requestId);
7616
8011
  }
7617
8012
  });
7618
8013
  return {