@taskforcehq/taskforce 0.3.306 → 0.3.307

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 (215) hide show
  1. package/README.md +0 -2
  2. package/dist/Taskforce.module.css +317 -27
  3. package/dist/TaskforceCore.js +1 -0
  4. package/dist/TaskforceCore.test.js +178 -0
  5. package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
  6. package/dist/components/activity/EntityActivityTimeline.js +81 -0
  7. package/dist/components/context/ContextAttachmentManager.js +15 -3
  8. package/dist/components/features/AgentsModule.d.ts +5 -1
  9. package/dist/components/features/AgentsModule.js +142 -39
  10. package/dist/components/features/AgentsModule.test.js +260 -11
  11. package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
  12. package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
  13. package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
  14. package/dist/components/features/DocumentIndex.d.ts +2 -2
  15. package/dist/components/features/DocumentIndex.js +3 -4
  16. package/dist/components/features/DocumentViewer.d.ts +3 -1
  17. package/dist/components/features/DocumentViewer.js +6 -5
  18. package/dist/components/features/DocumentWorkspace.d.ts +10 -2
  19. package/dist/components/features/DocumentWorkspace.js +15 -4
  20. package/dist/components/features/DocumentWorkspace.test.js +20 -3
  21. package/dist/components/features/TaskSettings.d.ts +2 -0
  22. package/dist/components/features/TaskSettings.js +83 -78
  23. package/dist/components/features/TaskSettings.test.js +171 -82
  24. package/dist/components/task/TaskActionHeader.js +11 -5
  25. package/dist/components/task/TaskActionHeader.test.js +10 -0
  26. package/dist/components/task/TaskCard.js +6 -2
  27. package/dist/components/task/TaskContextUpload.test.js +3 -3
  28. package/dist/components/task/TaskForm.js +14 -64
  29. package/dist/components/task/TaskKanban.js +0 -8
  30. package/dist/components/task/TaskList.js +5 -1
  31. package/dist/components/task/TaskList.test.js +4 -0
  32. package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
  33. package/dist/components/ui/HierarchyActionBadge.js +5 -0
  34. package/dist/components/views/AppShellHeader.d.ts +3 -1
  35. package/dist/components/views/AppShellHeader.js +27 -11
  36. package/dist/components/views/AppShellHeader.test.js +18 -1
  37. package/dist/components/views/PlanComparisonPage.js +9 -1
  38. package/dist/components/views/PlanComparisonPage.test.js +19 -0
  39. package/dist/components/views/PlansPage.js +1 -1
  40. package/dist/components/views/StandaloneLayout.js +256 -69
  41. package/dist/components/views/WidgetView.js +10 -8
  42. package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
  43. package/dist/components/views/panels/PlanningDrawer.js +259 -51
  44. package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
  45. package/dist/config/envSchema.js +1 -0
  46. package/dist/core/AiProfileService.d.ts +7 -0
  47. package/dist/core/AiProfileService.js +460 -35
  48. package/dist/core/AiProfiles.test.js +648 -2
  49. package/dist/core/AttachmentLinkService.d.ts +3 -0
  50. package/dist/core/AttachmentLinkService.js +58 -13
  51. package/dist/core/GlobalSettingsService.js +12 -0
  52. package/dist/core/McpTokenService.d.ts +12 -0
  53. package/dist/core/McpTokenService.js +44 -1
  54. package/dist/core/PlanningEntities.test.js +366 -4
  55. package/dist/core/TaskActivityService.d.ts +28 -1
  56. package/dist/core/TaskActivityService.js +227 -46
  57. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  58. package/dist/core/TaskActivitySync.test.js +90 -0
  59. package/dist/core/TaskAuditTimeline.test.js +96 -0
  60. package/dist/core/Taskforce.d.ts +190 -26
  61. package/dist/core/Taskforce.ids.test.js +58 -0
  62. package/dist/core/Taskforce.js +775 -71
  63. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  64. package/dist/core/types.d.ts +7 -0
  65. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
  66. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  67. package/dist/hooks/bootstrapTimeouts.js +8 -0
  68. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  69. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  70. package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
  71. package/dist/hooks/sync/orchestratorShared.js +61 -1
  72. package/dist/hooks/sync/recovery.js +8 -0
  73. package/dist/hooks/sync/recovery.test.js +5 -5
  74. package/dist/hooks/sync/transfers.d.ts +2 -1
  75. package/dist/hooks/sync/transfers.js +36 -9
  76. package/dist/hooks/sync/transfers.test.js +534 -2
  77. package/dist/hooks/useFilterSort.js +13 -13
  78. package/dist/hooks/useFilterSort.test.js +87 -1
  79. package/dist/hooks/useSyncOrchestrator.js +50 -5
  80. package/dist/hooks/useTaskforce.d.ts +85 -50
  81. package/dist/hooks/useTaskforce.js +44 -14
  82. package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
  83. package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
  84. package/dist/hooks/useWorkspaceSyncController.js +7 -1
  85. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  86. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
  87. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  88. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  89. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  90. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  91. package/dist/mcp/clientIntegrations.d.ts +1 -0
  92. package/dist/mcp/clientIntegrations.js +25 -0
  93. package/dist/mcp/clientIntegrations.test.js +14 -3
  94. package/dist/mcp/clientProfileDefaults.js +3 -3
  95. package/dist/mcp/collaborationRegistrar.js +7 -7
  96. package/dist/mcp/documentAssetRegistrar.js +113 -7
  97. package/dist/mcp/runtime.d.ts +1 -0
  98. package/dist/mcp/runtime.js +969 -122
  99. package/dist/mcp/runtime.test.js +1083 -11
  100. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  101. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  102. package/dist/mcp/toolRegistry.d.ts +56 -0
  103. package/dist/mcp/toolRegistry.js +8 -0
  104. package/dist/mcp/toolRegistry.test.js +4 -0
  105. package/dist/migrations/taskSchemaMigrations.js +21 -0
  106. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  107. package/dist/server/aiProfileAvatarAssets.js +319 -0
  108. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  109. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  110. package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
  111. package/dist/server/index.js +4 -0
  112. package/dist/server/index.test.js +6 -1
  113. package/dist/server/routes/admin.js +187 -9
  114. package/dist/server/routes/annotatedAttachments.js +51 -0
  115. package/dist/server/routes/auth.d.ts +11 -0
  116. package/dist/server/routes/auth.js +189 -27
  117. package/dist/server/routes/authSupport.d.ts +4 -0
  118. package/dist/server/routes/authSupport.js +12 -1
  119. package/dist/server/routes/billing.js +11 -0
  120. package/dist/server/routes/billing.test.js +3 -0
  121. package/dist/server/routes/documents.js +10 -0
  122. package/dist/server/routes/shared.js +0 -10
  123. package/dist/server/routes/sync.integration.test.js +221 -0
  124. package/dist/server/routes/sync.js +13 -3
  125. package/dist/server/routes/syncSnapshotRoutes.js +2 -0
  126. package/dist/server/routes/tasks.js +12 -9
  127. package/dist/server/routes.js +87 -8
  128. package/dist/server/routes.test.js +890 -22
  129. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  130. package/dist/shared/aiProfileSurfaceType.js +10 -8
  131. package/dist/storage/objectStorage.d.ts +1 -0
  132. package/dist/storage/objectStorage.js +2 -1
  133. package/dist/storage/objectStorageClient.d.ts +1 -0
  134. package/dist/storage/objectStorageClient.js +41 -8
  135. package/dist/storage/objectStorageClient.test.js +26 -1
  136. package/dist/storage/objectStorageConfig.js +3 -1
  137. package/dist/storage/objectStorageConfig.test.js +13 -1
  138. package/dist/sync/collaborationSyncPayload.js +2 -0
  139. package/dist/sync/collaborationSyncPayload.test.js +1 -0
  140. package/dist/sync/collaborationSyncState.d.ts +3 -3
  141. package/dist/sync/collaborationSyncState.js +19 -12
  142. package/dist/sync/contentSyncPayload.d.ts +5 -1
  143. package/dist/sync/contentSyncPayload.js +9 -0
  144. package/dist/sync/contentSyncPayload.test.js +39 -0
  145. package/dist/sync/contentSyncState.d.ts +5 -1
  146. package/dist/sync/contentSyncState.js +231 -37
  147. package/dist/sync/planningSyncPayload.js +5 -0
  148. package/dist/sync/planningSyncPayload.test.js +24 -0
  149. package/dist/sync/syncApplyHandlers.d.ts +4 -4
  150. package/dist/sync/syncApplyHandlers.js +89 -31
  151. package/dist/sync/syncApplyHandlers.test.js +184 -4
  152. package/dist/sync/syncResourceAdapters.d.ts +2 -0
  153. package/dist/sync/syncResourceAdapters.js +2 -0
  154. package/dist/sync/syncService.d.ts +1 -0
  155. package/dist/sync/syncService.js +13 -1
  156. package/dist/sync/syncService.test.js +39 -0
  157. package/dist/sync/workspacePullFeed.d.ts +4 -0
  158. package/dist/sync/workspacePullFeed.js +129 -70
  159. package/dist/sync/workspacePullFeed.test.js +49 -0
  160. package/dist/sync/workspaceSyncModel.d.ts +28 -6
  161. package/dist/sync/workspaceSyncModel.js +159 -45
  162. package/dist/sync/workspaceSyncModel.test.js +269 -3
  163. package/dist/types.d.ts +33 -2
  164. package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
  165. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
  166. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  167. package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
  168. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  169. package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
  170. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  171. package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
  172. package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-D4XseTwW.js} +1 -1
  173. package/dist/ui/assets/PlansPage-D5AcbO0L.js +1 -0
  174. package/dist/ui/assets/PlansPage-Dvqsz5zc.css +1 -0
  175. package/dist/ui/assets/TaskContextUpload-uC5qx4Bv.js +1 -0
  176. package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
  177. package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-oOA9bcdK.js} +4 -4
  178. package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
  179. package/dist/ui/assets/index-607WPo_X.js +4 -0
  180. package/dist/ui/assets/index-CYyodXsg.css +1 -0
  181. package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
  182. package/dist/ui/index.html +3 -3
  183. package/dist/ui/product/agent manager.png +0 -0
  184. package/dist/ui/product/agent_manager_02.png +0 -0
  185. package/dist/utils/aiProfileEvents.d.ts +7 -0
  186. package/dist/utils/aiProfileEvents.js +6 -0
  187. package/dist/utils/assignees.d.ts +6 -0
  188. package/dist/utils/assignees.js +8 -1
  189. package/dist/utils/assignees.test.d.ts +1 -0
  190. package/dist/utils/assignees.test.js +25 -0
  191. package/dist/utils/avatarDisplay.d.ts +1 -0
  192. package/dist/utils/avatarDisplay.js +15 -0
  193. package/dist/utils/contextFiles.d.ts +3 -1
  194. package/dist/utils/contextFiles.js +2 -1
  195. package/dist/utils/taskActivity.d.ts +7 -1
  196. package/dist/utils/taskActivity.js +63 -1
  197. package/dist/utils/taskActivity.test.js +23 -1
  198. package/dist/utils/taskSearch.d.ts +22 -0
  199. package/dist/utils/taskSearch.js +87 -0
  200. package/dist/utils/taskSearch.test.d.ts +1 -0
  201. package/dist/utils/taskSearch.test.js +69 -0
  202. package/dist/utils/taskUtils.js +10 -3
  203. package/dist/utils/taskUtils.test.d.ts +1 -0
  204. package/dist/utils/taskUtils.test.js +19 -0
  205. package/package.json +1 -1
  206. package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
  207. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  208. package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
  209. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
  210. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
  211. package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
  212. package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
  213. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
  214. package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
  215. package/dist/ui/assets/index-C6k75jr8.js +0 -6
@@ -3,7 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import { CallToolRequestSchema, ListResourceTemplatesRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
- import { randomUUID } from 'node:crypto';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
7
  import * as os from 'node:os';
8
8
  import { TaskforceCore, TaskforceRuleError } from '../core/Taskforce.js'; // Note .js extension for ESM
9
9
  import { COMPLEXITY_OPTIONS, resolveNumberOption, resolveStringOption, STATUS_OPTIONS } from './optionResolution.js';
@@ -15,8 +15,9 @@ import { validateStructuredDocForAttach } from './structuredDocValidation.js';
15
15
  import { validateStructuredCommentForAdd } from './structuredCommentValidation.js';
16
16
  import { resolveDatabaseConfigFromEnv } from '../storage/providerConfig.js';
17
17
  import { resolveObjectStorageConfigFromEnv } from '../storage/objectStorageConfig.js';
18
- import { ObjectStorageClient } from '../storage/objectStorageClient.js';
18
+ import { ObjectStorageClient, normalizeTaskDataRelativePath } from '../storage/objectStorageClient.js';
19
19
  import { WorkspaceAssetStore } from '../storage/workspaceAssetStore.js';
20
+ import { resolveVirusScanConfigFromEnv, scanBufferForThreats } from '../security/virusScan.js';
20
21
  import { ANNOTATED_ATTACHMENTS_WORKSPACE_FEATURE_KEY, resolveAppGateAccess, } from '../runtime/appGates.js';
21
22
  import { getDocumentReferenceLabel, parseDocumentReference } from '../utils/documentReferences.js';
22
23
  import { getImageReferenceLabel, parseImageReference } from '../utils/imageReferences.js';
@@ -24,12 +25,30 @@ import { inferCanonicalAssetKind, isValidCanonicalDocumentAsset } from '../utils
24
25
  import { getTaskReferenceLabel, shouldUseProvisionalTaskReferences } from '../utils/taskReferences.js';
25
26
  import { parseAiProfileSurfaceType } from '../shared/aiProfileSurfaceType.js';
26
27
  import { parseAiProfileSeatScope } from '../shared/aiProfileSeatScope.js';
27
- import { formatWorkstreamReference, resolveInitiativeReference, parseWorkstreamReference, } from '../utils/planningReferences.js';
28
+ import { formatInitiativeReference, formatWorkstreamReference, resolveInitiativeReference, parseWorkstreamReference, } from '../utils/planningReferences.js';
28
29
  import { MCP_TOOL_PERMISSIONS, MCP_WRITE_TOOL_NAMES } from './toolRegistry.js';
29
30
  import { registerAdminWorkspaceTools } from './adminWorkspaceRegistrar.js';
30
31
  import { registerCollaborationTools } from './collaborationRegistrar.js';
31
32
  import { registerDocumentAssetTools } from './documentAssetRegistrar.js';
32
33
  import { registerTaskPlanningTools } from './taskPlanningRegistrar.js';
34
+ function readEnvValue(name) {
35
+ const value = String(process.env[name] || '').trim();
36
+ return value || null;
37
+ }
38
+ function buildStdioAuthFromEnv() {
39
+ return {
40
+ aiProfileToken: readEnvValue('TASKFORCE_AI_PROFILE_TOKEN'),
41
+ aiProfileName: readEnvValue('TASKFORCE_AI_NAME'),
42
+ aiProfileIcon: readEnvValue('TASKFORCE_AI_ICON'),
43
+ aiProfileColor: readEnvValue('TASKFORCE_AI_COLOR'),
44
+ aiProfileDescription: readEnvValue('TASKFORCE_AI_DESCRIPTION'),
45
+ aiProfileRole: readEnvValue('TASKFORCE_AI_ROLE'),
46
+ aiProfileProvider: readEnvValue('TASKFORCE_AI_PROVIDER'),
47
+ aiProfileModel: readEnvValue('TASKFORCE_AI_MODEL'),
48
+ aiProfileSurfaceType: parseAiProfileSurfaceType(readEnvValue('TASKFORCE_AI_SURFACE_TYPE')),
49
+ aiProfileSeatScope: parseAiProfileSeatScope(readEnvValue('TASKFORCE_AI_SEAT_SCOPE')),
50
+ };
51
+ }
33
52
  export function isMcpWriteToolName(name) {
34
53
  return MCP_WRITE_TOOL_NAMES.has(String(name || '').trim());
35
54
  }
@@ -81,7 +100,7 @@ function resolveRecoverableToolError(error) {
81
100
  code: 'AI_PROFILE_REQUIRED',
82
101
  nextTool: 'resolve_profile',
83
102
  suggestedArgs: {},
84
- retryHint: 'Call get_current_profile to inspect binding state first, or call resolve_profile to bind an AI identity. Only pass profileToken on later mutating calls if your client is stateless.'
103
+ retryHint: 'Call get_current_profile to inspect binding state first, or call resolve_profile to bind an AI identity. Store the returned profileToken only in private agent/client state and pass it on later mutating calls only if your client is stateless.'
85
104
  };
86
105
  }
87
106
  if (/^Invalid AI profile token\./i.test(message)) {
@@ -90,7 +109,7 @@ function resolveRecoverableToolError(error) {
90
109
  code: 'INVALID_AI_PROFILE_TOKEN',
91
110
  nextTool: 'resolve_profile',
92
111
  suggestedArgs: {},
93
- retryHint: 'Call get_current_profile to confirm whether ambient identity is already bound. If not, call resolve_profile again to obtain a fresh profileToken, then retry the original mutating tool call.'
112
+ retryHint: 'Call get_current_profile to confirm whether ambient identity is already bound. If not, call resolve_profile again to obtain a fresh profileToken for private agent/client storage, then retry the original mutating tool call once.'
94
113
  };
95
114
  }
96
115
  if (error instanceof TaskforceRuleError) {
@@ -116,12 +135,29 @@ function resolveRecoverableToolError(error) {
116
135
  }
117
136
  if (!message)
118
137
  return null;
138
+ const transient = classifyTransientMcpError(error);
139
+ if (transient) {
140
+ return {
141
+ message,
142
+ code: transient.code,
143
+ kind: transient.kind,
144
+ retryable: true,
145
+ details: {
146
+ kind: transient.kind,
147
+ retryable: true
148
+ },
149
+ retryHint: transient.retryHint
150
+ };
151
+ }
119
152
  const safePatterns = [
120
153
  /^Invalid /i,
121
154
  / is required$/i,
122
- /^Task .+ not found$/i,
155
+ /^attachments must be an array$/i,
156
+ /^Task .+ not found/i,
157
+ /^Initiative .+ not found/i,
158
+ /^Workstream .+ not found/i,
123
159
  /^Image .+ not found in workspace .+$/i,
124
- /^Attachment .+ not found on task .+$/i,
160
+ /^Attachment .+ not found on (task|initiative|workstream) .+$/i,
125
161
  /^Attachment is quarantined$/i,
126
162
  /^Attachment scan pending$/i,
127
163
  /^Attachment is not readable as text$/i,
@@ -141,6 +177,53 @@ function resolveRecoverableToolError(error) {
141
177
  }
142
178
  return null;
143
179
  }
180
+ function collectErrorMessages(error) {
181
+ const messages = [];
182
+ let current = error;
183
+ const seen = new Set();
184
+ while (current && !seen.has(current)) {
185
+ seen.add(current);
186
+ if (current instanceof Error && current.message) {
187
+ messages.push(String(current.message));
188
+ current = current.cause;
189
+ continue;
190
+ }
191
+ if (typeof current === 'object') {
192
+ const record = current;
193
+ if (typeof record.message === 'string')
194
+ messages.push(record.message);
195
+ if (typeof record.code === 'string')
196
+ messages.push(record.code);
197
+ current = record.cause;
198
+ continue;
199
+ }
200
+ break;
201
+ }
202
+ return messages;
203
+ }
204
+ function classifyTransientMcpError(error) {
205
+ const combined = collectErrorMessages(error).join(' ').trim();
206
+ if (!combined)
207
+ return null;
208
+ if (/(fetch failed|ECONNRESET|ECONNREFUSED|EPIPE|socket hang up|UND_ERR_|network error|request aborted|connection terminated)/i.test(combined)) {
209
+ return {
210
+ kind: 'transport_failure',
211
+ code: 'MCP_TRANSPORT_FAILURE',
212
+ retryHint: 'The tool transport failed before Taskforce could return a stable tool response. Retry the same read once; if it repeats, inspect the cloud MCP route logs with this requestId.'
213
+ };
214
+ }
215
+ if (/(timed out|timeout|ETIMEDOUT|query canceled|statement timeout|database timeout|postgres worker)/i.test(combined)) {
216
+ return {
217
+ kind: 'backend_timeout',
218
+ code: 'MCP_BACKEND_TIMEOUT',
219
+ retryHint: 'Taskforce reached the backend but the read timed out. Retry the same read after a short delay; repeated failures point to backend saturation rather than a missing document or attachment.'
220
+ };
221
+ }
222
+ return null;
223
+ }
224
+ function delay(ms) {
225
+ return new Promise((resolve) => setTimeout(resolve, ms));
226
+ }
144
227
  function inferMimeTypeFromPath(filePath) {
145
228
  const ext = path.extname(filePath).toLowerCase();
146
229
  const contentTypes = {
@@ -160,6 +243,22 @@ function inferMimeTypeFromPath(filePath) {
160
243
  function inferAssetKind(filePath, mimeType) {
161
244
  return inferCanonicalAssetKind(filePath, mimeType);
162
245
  }
246
+ function scanVerdictToCanonicalStatus(verdict) {
247
+ if (verdict === 'pending')
248
+ return 'pending';
249
+ if (verdict === 'infected')
250
+ return 'infected';
251
+ if (verdict === 'error')
252
+ return 'failed';
253
+ return 'clean';
254
+ }
255
+ function inferWorkspaceIdFromStoragePath(pathValue) {
256
+ const normalized = String(pathValue || '').trim().replace(/\\/g, '/');
257
+ const match = normalized.match(/^workspaces\/([^/]+)\//);
258
+ if (match && match[1])
259
+ return match[1];
260
+ return 'default';
261
+ }
163
262
  function isTextReadableMimeType(mimeTypeRaw) {
164
263
  const mimeType = String(mimeTypeRaw || '').trim().toLowerCase();
165
264
  if (!mimeType)
@@ -213,6 +312,8 @@ export function buildRecoverableToolErrorResult(error, requestId) {
213
312
  error: resolved.message,
214
313
  message: resolved.message,
215
314
  ...(resolved.code ? { code: resolved.code } : {}),
315
+ ...(resolved.kind ? { kind: resolved.kind } : {}),
316
+ ...(resolved.retryable ? { retryable: true } : {}),
216
317
  ...(resolved.details !== undefined ? { details: resolved.details } : {}),
217
318
  ...(resolved.needs_confirmation ? { needs_confirmation: true } : {}),
218
319
  ...(resolved.nextTool ? { nextTool: resolved.nextTool } : {}),
@@ -239,8 +340,7 @@ export function createTaskforceMcpServer(options = {}) {
239
340
  if (logStartup) {
240
341
  console.error(`[Taskforce MCP] Starting for project: ${PROJECT_NAME} at ${PROJECT_ROOT}`);
241
342
  }
242
- const db = resolveDatabaseConfigFromEnv();
243
- const tenantId = String(options.tenantId || process.env.TASKFORCE_TENANT_ID || 'default').trim() || 'default';
343
+ const tenantId = String(options.tenantId || options.core?.getTenantId() || process.env.TASKFORCE_TENANT_ID || 'default').trim() || 'default';
244
344
  const MCP_SERVER_VERSION = (() => {
245
345
  if (options.version)
246
346
  return String(options.version).trim() || '0.0.0-dev';
@@ -255,14 +355,17 @@ export function createTaskforceMcpServer(options = {}) {
255
355
  return '0.0.0-dev';
256
356
  }
257
357
  })();
258
- const core = new TaskforceCore({
259
- projectRoot: PROJECT_ROOT,
260
- tenantId,
261
- database: {
262
- provider: db.provider,
263
- connectionString: db.connectionString
264
- }
265
- });
358
+ const core = options.core || (() => {
359
+ const db = resolveDatabaseConfigFromEnv();
360
+ return new TaskforceCore({
361
+ projectRoot: PROJECT_ROOT,
362
+ tenantId,
363
+ database: {
364
+ provider: db.provider,
365
+ connectionString: db.connectionString
366
+ }
367
+ });
368
+ })();
266
369
  const workspaceFromEnv = String(options.workspaceId || process.env.TASKFORCE_WORKSPACE_ID || '').trim();
267
370
  let ACTIVE_WORKSPACE_ID = '';
268
371
  const workspaceFromEnvValid = workspaceFromEnv && workspaceFromEnv.toLowerCase() !== 'default'
@@ -342,7 +445,7 @@ export function createTaskforceMcpServer(options = {}) {
342
445
  },
343
446
  tools: {},
344
447
  },
345
- instructions: `You are an AI collaborator connected to Taskforce, a shared task management system for AI-developer pairs.\n\nIMPORTANT: Before mutating Taskforce data, ensure Taskforce knows which AI profile you are acting as. The preferred path is ambient session binding: if your MCP connection is configured with x-taskforce-ai-name and optionally x-taskforce-ai-profile-token headers, Taskforce can auto-bind that AI identity for get_current_profile and later writes. If no AI identity is currently bound, call resolve_profile with your name. Reuse the returned profileToken across sessions, especially for stateless clients.\n\nStateless client pattern: call resolve_profile, save the returned profileToken, and include profileToken on later mutating tool calls only when your MCP client does not preserve session identity between calls.\n\nTo inspect your current attribution state before writes, call get_current_profile. It reports whether identity is bound, how it was resolved, and the suggested args to use with list_tasks.\n\nWhen setting resolve_profile.surfaceType, classify yourself by the kind of identity you are registering: use agent for a persistent named AI collaborator in Taskforce, coding_tool for the coding surface or terminal tool itself, chat_app for a conversational app identity, ide for an IDE-native copilot identity, and other only when none of those fit. If you are unsure and you are registering a named collaborator such as a teammate-style AI persona, choose agent.\n\nTo get tasks assigned to yourself, call get_current_profile, then use the returned suggestedArgs with list_tasks. When you begin active work on a tracked task, prefer start_task. It sets the task to in-progress and claims it for your AI profile when the task is currently unassigned. When work on a tracked task is complete, prefer finish_task for the final handoff: use outcome='review' when the next step is human validation or approval, and use outcome='done' only when no explicit human action is requested. Do not leave a task in-progress after reporting completed implementation unless the user explicitly wants more work to continue in the same task.\n\nTaxonomy rule: always call get_config before setting category, type, priority, approach, status, or complexity. Do not rely on remembered enum values.\n\nUse identifier prefixes to choose the correct retrieval path: T-123 goes to task tools such as get_task, IN-123 goes to initiative tools such as get_initiative, WS-123 goes to workstream tools such as get_workstream, I-123 goes to image tools such as get_image, and D-123 goes to document tools such as get_document. Do not send I-123 or D-123 identifiers to get_task.\n\nFor annotated images, use search_images first when you know only a keyword, filename, or rough title. Once you have the exact identifier, call get_image, then use get_annotated_attachment_sessions to list saved sessions, then use read_annotated_attachment_payload for the structured annotation payload. If an image reference is being used for UI, placement, or visual correction feedback, treat the saved annotation payload as the source of truth and do not infer intent from the screenshot alone.\n\nFor documents, use search_documents first when you know only a keyword, filename, or rough title. Once you have the exact identifier, call get_document, then read_document_content to inspect the document body. For a lower-friction review flow, prefer review_document; otherwise use get_document_review_sessions or create_document_review_session, then add_document_review_comment to record review feedback.`,
448
+ instructions: `You are an AI collaborator connected to Taskforce, a shared task management system for AI-developer pairs.\n\nIMPORTANT: Before mutating Taskforce data, ensure Taskforce knows which AI profile you are acting as. The preferred path is ambient session binding: if your MCP connection is configured with x-taskforce-ai-name and optionally x-taskforce-ai-profile-token headers, Taskforce can auto-bind that AI identity for get_current_profile and later writes. If no AI identity is currently bound, call resolve_profile with your name. Reuse the returned profileToken across sessions, especially for stateless clients.\n\nStateless client pattern: call resolve_profile, save the returned profileToken only in private agent/client state, and include profileToken on later mutating tool calls only when your MCP client does not preserve session identity between calls. Never write profileToken values to repos, task comments, task attachments, logs, generated docs, or shared output.\n\nTo inspect your current attribution state before writes, call get_current_profile. It reports whether identity is bound, how it was resolved, and the suggested args to use with list_tasks.\n\nWhen setting resolve_profile.surfaceType, classify yourself by the kind of identity you are registering: use agent for a persistent named AI collaborator in Taskforce, cli for terminal-native coding agents, coding_tool for coding surfaces that are not specifically CLI or IDE-native, chat_app for a conversational app identity, and ide for an IDE-native copilot identity. If you are unsure or none of those fit, choose agent.\n\nTo get tasks assigned to yourself, call get_current_profile, then use the returned suggestedArgs with list_tasks. When you begin active work on a tracked task, prefer start_task. It sets the task to in-progress and claims it for your AI profile when the task is currently unassigned. When work on a tracked task is complete, prefer finish_task for the final handoff: use outcome='review' when the next step is human validation or approval, and use outcome='done' only when no explicit human action is requested. Do not leave a task in-progress after reporting completed implementation unless the user explicitly wants more work to continue in the same task.\n\nTaxonomy rule: always call get_config before setting category, type, priority, approach, status, or complexity. Do not rely on remembered enum values.\n\nUse identifier prefixes to choose the correct retrieval path: T-123 goes to task tools such as get_task, IN-123 goes to initiative tools such as get_initiative, WS-123 goes to workstream tools such as get_workstream, I-123 goes to image tools such as get_image, and D-123 goes to document tools such as get_document. Do not send I-123 or D-123 identifiers to get_task.\n\nFor annotated images, use search_images first when you know only a keyword, filename, or rough title. Once you have the exact identifier, call get_image, then use get_annotated_attachment_sessions to list saved sessions, then use read_annotated_attachment_payload for the structured annotation payload. If an image reference is being used for UI, placement, or visual correction feedback, treat the saved annotation payload as the source of truth and do not infer intent from the screenshot alone.\n\nFor documents, use search_documents first when you know only a keyword, filename, or rough title. Once you have the exact identifier, call get_document, then read_document_content to inspect the document body. For a lower-friction review flow, prefer review_document; otherwise use get_document_review_sessions or create_document_review_session, then add_document_review_comment to record review feedback.`,
346
449
  });
347
450
  const TOOL_PERMISSIONS = MCP_TOOL_PERMISSIONS;
348
451
  const RESOURCE_PERMISSIONS = {
@@ -653,7 +756,7 @@ export function createTaskforceMcpServer(options = {}) {
653
756
  }
654
757
  const previousStatus = String(task?.status || 'task').trim() || 'task';
655
758
  const previousAssignee = String(task?.assignee || '').trim() || 'unassigned';
656
- if (previousStatus === 'done' || previousStatus === 'review' || previousStatus === 'cancelled') {
759
+ if (previousStatus === 'done' || previousStatus === 'cancelled') {
657
760
  throw new Error(`Task ${formatTaskIdentifier(task)} is already ${previousStatus} and must be reopened explicitly before active work resumes.`);
658
761
  }
659
762
  if (previousAssignee !== 'unassigned' && previousAssignee !== currentAiAssignee) {
@@ -690,6 +793,26 @@ export function createTaskforceMcpServer(options = {}) {
690
793
  return auditActorUserId;
691
794
  return undefined;
692
795
  }
796
+ function isStableMcpCommentActorRef(value) {
797
+ const normalized = String(value || '').trim().toLowerCase();
798
+ if (!normalized)
799
+ return false;
800
+ return normalized === 'user'
801
+ || normalized.startsWith('user-')
802
+ || normalized.startsWith('ai-profile-');
803
+ }
804
+ function resolveMcpCommentAuthorInput(value) {
805
+ const raw = String(value || '').trim();
806
+ const normalized = raw.toLowerCase();
807
+ const activeActor = getActiveMcpActorRef();
808
+ if (!raw || normalized === 'system')
809
+ return activeActor;
810
+ if (isStableMcpCommentActorRef(raw))
811
+ return raw;
812
+ if (activeActor !== 'system')
813
+ return activeActor;
814
+ return raw;
815
+ }
693
816
  function resolveProfileTokenFromArgs(args) {
694
817
  if (!args || typeof args !== 'object' || Array.isArray(args))
695
818
  return null;
@@ -946,8 +1069,8 @@ export function createTaskforceMcpServer(options = {}) {
946
1069
  };
947
1070
  }
948
1071
  function createAnnotatedAttachmentAnnotationFromArgs(args, order) {
949
- const kind = normalizeAnnotatedAttachmentKind(args.kind);
950
- const instruction = normalizeOptionalString(args.instruction);
1072
+ const kind = normalizeAnnotatedAttachmentKind(args.kind ?? args.type);
1073
+ const instruction = normalizeOptionalString(args.instruction ?? args.text);
951
1074
  if (!instruction) {
952
1075
  throw new Error('instruction is required');
953
1076
  }
@@ -1107,6 +1230,61 @@ export function createTaskforceMcpServer(options = {}) {
1107
1230
  ...details,
1108
1231
  });
1109
1232
  }
1233
+ const burstyQaReadToolNames = new Set([
1234
+ 'get_initiative',
1235
+ 'get_workstream',
1236
+ 'list_initiatives',
1237
+ 'list_workstreams',
1238
+ 'get_workstream_attachments',
1239
+ 'get_initiative_attachments',
1240
+ 'get_document',
1241
+ 'read_document_content',
1242
+ ]);
1243
+ const burstyQaReadRetryDelaysMs = [125, 350];
1244
+ async function executeMcpToolWithReadRetry(toolName, args, handler) {
1245
+ const startedAtMs = Date.now();
1246
+ let attempt = 0;
1247
+ for (;;) {
1248
+ try {
1249
+ const result = await handler(args);
1250
+ if (burstyQaReadToolNames.has(toolName)) {
1251
+ logMcpReadObservation(toolName, startedAtMs, {
1252
+ attempts: attempt + 1,
1253
+ retried: attempt > 0,
1254
+ });
1255
+ }
1256
+ return result;
1257
+ }
1258
+ catch (error) {
1259
+ const transient = classifyTransientMcpError(error);
1260
+ if (!burstyQaReadToolNames.has(toolName) || !transient || attempt >= burstyQaReadRetryDelaysMs.length) {
1261
+ if (burstyQaReadToolNames.has(toolName) && transient) {
1262
+ logMcpStructuredEvent('warn', 'tool_read_retry_exhausted', {
1263
+ toolName,
1264
+ attempts: attempt + 1,
1265
+ durationMs: Date.now() - startedAtMs,
1266
+ errorKind: transient.kind,
1267
+ errorCode: transient.code,
1268
+ });
1269
+ }
1270
+ throw error;
1271
+ }
1272
+ const delayMs = burstyQaReadRetryDelaysMs[attempt];
1273
+ attempt += 1;
1274
+ logMcpStructuredEvent('warn', 'tool_read_retry', {
1275
+ toolName,
1276
+ attempt,
1277
+ nextAttempt: attempt + 1,
1278
+ delayMs,
1279
+ durationMs: Date.now() - startedAtMs,
1280
+ errorKind: transient.kind,
1281
+ errorCode: transient.code,
1282
+ args: sanitizeToolArgsForLog(args),
1283
+ });
1284
+ await delay(delayMs);
1285
+ }
1286
+ }
1287
+ }
1110
1288
  /**
1111
1289
  * Helpers
1112
1290
  */
@@ -1601,6 +1779,61 @@ export function createTaskforceMcpServer(options = {}) {
1601
1779
  workstreams,
1602
1780
  };
1603
1781
  }
1782
+ function buildPlanningWriteResponseHydrationFallbackDetails(targetType, entity, context, error) {
1783
+ const transient = classifyTransientMcpError(error);
1784
+ if (!transient)
1785
+ throw error;
1786
+ const referenceLabel = targetType === 'initiative'
1787
+ ? (typeof entity?.referenceLabel === 'string' ? entity.referenceLabel : formatInitiativeReference(entity))
1788
+ : (typeof entity?.referenceLabel === 'string' ? entity.referenceLabel : formatWorkstreamReference(entity));
1789
+ logMcpStructuredEvent('warn', 'planning_write_response_hydration_fallback', {
1790
+ context,
1791
+ targetType,
1792
+ targetId: entity?.id || null,
1793
+ referenceLabel,
1794
+ errorKind: transient.kind,
1795
+ errorCode: transient.code,
1796
+ message: error instanceof Error ? error.message : String(error),
1797
+ });
1798
+ return {
1799
+ fallback: true,
1800
+ reason: transient.kind,
1801
+ code: transient.code,
1802
+ retryable: true,
1803
+ message: 'The write completed, but Taskforce skipped the richer response hydration after a transient read timeout.',
1804
+ nextTool: targetType === 'initiative' ? 'get_initiative' : 'get_workstream',
1805
+ suggestedArgs: {
1806
+ id: referenceLabel || entity?.id || '',
1807
+ },
1808
+ };
1809
+ }
1810
+ function buildPostWriteInitiativeResourcePayload(initiative, context) {
1811
+ try {
1812
+ return buildInitiativeResourcePayload(initiative);
1813
+ }
1814
+ catch (error) {
1815
+ return {
1816
+ initiative: buildPlanningInitiativeSummary(initiative),
1817
+ workstreamCount: 0,
1818
+ taskCount: 0,
1819
+ workstreams: [],
1820
+ responseHydration: buildPlanningWriteResponseHydrationFallbackDetails('initiative', initiative, context, error),
1821
+ };
1822
+ }
1823
+ }
1824
+ function buildPostWriteWorkstreamResourcePayload(workstream, context) {
1825
+ try {
1826
+ return buildWorkstreamResourcePayload(workstream);
1827
+ }
1828
+ catch (error) {
1829
+ return {
1830
+ workstream: buildPlanningWorkstreamSummary(workstream),
1831
+ taskCount: 0,
1832
+ tasks: [],
1833
+ responseHydration: buildPlanningWriteResponseHydrationFallbackDetails('workstream', workstream, context, error),
1834
+ };
1835
+ }
1836
+ }
1604
1837
  function buildInitiativeLookupFailureMessage(initiativeIdOrReference) {
1605
1838
  const normalizedValue = String(initiativeIdOrReference || '').trim() || 'initiative';
1606
1839
  return `Initiative ${normalizedValue} not found in workspace ${ACTIVE_WORKSPACE_ID}.`;
@@ -1892,9 +2125,314 @@ export function createTaskforceMcpServer(options = {}) {
1892
2125
  tools,
1893
2126
  };
1894
2127
  });
1895
- /**
1896
- * Tool Execution
1897
- */
2128
+ const resolvePlanningAttachmentTarget = (targetType, inputId) => {
2129
+ if (targetType === 'initiative') {
2130
+ const { id, initiative } = resolveInitiativeByIdentifierOrThrow(inputId);
2131
+ return {
2132
+ type: targetType,
2133
+ id,
2134
+ entity: initiative,
2135
+ referenceLabel: typeof initiative.referenceLabel === 'string' ? initiative.referenceLabel : formatInitiativeReference(initiative),
2136
+ };
2137
+ }
2138
+ const { id, workstream } = resolveWorkstreamByIdentifierOrThrow(inputId);
2139
+ return {
2140
+ type: targetType,
2141
+ id,
2142
+ entity: workstream,
2143
+ referenceLabel: typeof workstream.referenceLabel === 'string' ? workstream.referenceLabel : formatWorkstreamReference(workstream),
2144
+ };
2145
+ };
2146
+ const formatPlanningAttachmentTarget = (target) => {
2147
+ const reference = String(target.referenceLabel || '').trim();
2148
+ if (reference)
2149
+ return reference;
2150
+ const title = String(target.entity?.title || '').trim();
2151
+ if (title)
2152
+ return `"${title}"`;
2153
+ return target.id;
2154
+ };
2155
+ const listPlanningAttachmentDescriptors = (target) => (taskAttachmentHelpers.listTaskAttachmentDescriptors({
2156
+ id: target.id,
2157
+ attachments: Array.isArray(target.entity?.attachments) ? target.entity.attachments : [],
2158
+ }));
2159
+ const updatePlanningAttachmentTarget = (targetType, targetId, attachments) => (targetType === 'initiative'
2160
+ ? core.updateInitiative(targetId, { attachments }, ACTIVE_WORKSPACE_ID)
2161
+ : core.updateWorkstream(targetId, { attachments }, ACTIVE_WORKSPACE_ID));
2162
+ const notifyPlanningAttachmentMutation = async (targetType, targetId, entity) => {
2163
+ if (targetType === 'initiative') {
2164
+ await notifyResourceMutation({
2165
+ includeInitiatives: true,
2166
+ initiativeIds: [targetId],
2167
+ });
2168
+ return;
2169
+ }
2170
+ await notifyResourceMutation({
2171
+ includeWorkstreams: true,
2172
+ workstreamIds: [targetId],
2173
+ ...(entity?.initiativeId ? { includeInitiatives: true, initiativeIds: [entity.initiativeId] } : {}),
2174
+ });
2175
+ };
2176
+ const buildPlanningAttachmentListPayload = (targetType, target) => ({
2177
+ [`${targetType}Id`]: target.id,
2178
+ referenceLabel: target.referenceLabel,
2179
+ attachments: listPlanningAttachmentDescriptors(target),
2180
+ });
2181
+ const readPlanningAttachment = async (targetType, args) => {
2182
+ const inputId = targetType === 'initiative' ? args.initiativeId : args.workstreamId;
2183
+ const { attachmentId, format = "text", maxChars = 200000 } = args;
2184
+ const target = resolvePlanningAttachmentTarget(targetType, inputId);
2185
+ const attachments = listPlanningAttachmentDescriptors(target);
2186
+ const attachment = attachments.find((entry) => entry.attachmentId === attachmentId);
2187
+ if (!attachment) {
2188
+ throw new Error(`Attachment ${attachmentId} not found on ${targetType} ${String(inputId || '').trim() || target.id}`);
2189
+ }
2190
+ if (format === "metadata") {
2191
+ return {
2192
+ content: [{
2193
+ type: "text",
2194
+ text: JSON.stringify({
2195
+ [`${targetType}Id`]: target.id,
2196
+ attachment,
2197
+ format: "metadata"
2198
+ }, null, 2)
2199
+ }],
2200
+ };
2201
+ }
2202
+ if (format === "download") {
2203
+ return {
2204
+ content: [{
2205
+ type: "text",
2206
+ text: JSON.stringify({
2207
+ [`${targetType}Id`]: target.id,
2208
+ attachment,
2209
+ format: "download",
2210
+ downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
2211
+ reason: attachment.source === 'external' ? 'external_reference' : 'requested_download'
2212
+ }, null, 2)
2213
+ }],
2214
+ };
2215
+ }
2216
+ if (!attachment.readableAsText) {
2217
+ return {
2218
+ content: [{
2219
+ type: "text",
2220
+ text: JSON.stringify({
2221
+ [`${targetType}Id`]: target.id,
2222
+ attachment,
2223
+ format: "download",
2224
+ downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
2225
+ reason: attachment.source === 'external' ? 'external_reference' : 'binary_file'
2226
+ }, null, 2)
2227
+ }],
2228
+ };
2229
+ }
2230
+ const resolvedMaxChars = Math.max(1, Math.floor(Number(maxChars || 200000)));
2231
+ const buffer = await canonicalAssetHelpers.readCanonicalAttachmentBuffer(target.id, attachment, targetType);
2232
+ const content = buffer.toString('utf8');
2233
+ if (content.length > resolvedMaxChars) {
2234
+ throw new Error('Attachment exceeds maxChars limit for inline read');
2235
+ }
2236
+ return {
2237
+ content: [{
2238
+ type: "text",
2239
+ text: JSON.stringify({
2240
+ [`${targetType}Id`]: target.id,
2241
+ attachment,
2242
+ format: "text",
2243
+ truncated: false,
2244
+ content
2245
+ }, null, 2)
2246
+ }],
2247
+ };
2248
+ };
2249
+ const savePlanningAttachment = async (targetType, args) => {
2250
+ const { id: inputId, filename, content, sourcePath, caption, overwrite = false } = args;
2251
+ const target = resolvePlanningAttachmentTarget(targetType, inputId);
2252
+ if (!filename || typeof filename !== 'string')
2253
+ throw new Error("filename is required");
2254
+ const hasContent = typeof content === 'string';
2255
+ const hasSourcePath = typeof sourcePath === 'string' && sourcePath.trim().length > 0;
2256
+ if (hasContent === hasSourcePath) {
2257
+ throw new Error("Provide exactly one of: content or sourcePath");
2258
+ }
2259
+ const now = new Date().toISOString();
2260
+ const safeCaption = typeof caption === 'string' && caption.trim().length > 0
2261
+ ? caption.trim()
2262
+ : undefined;
2263
+ const safeFilename = sanitizeFilenameForPath(filename);
2264
+ const { projectRoot, basePath } = core.getPaths();
2265
+ const targetForOverwrite = {
2266
+ id: target.id,
2267
+ attachments: Array.isArray(target.entity.attachments) ? target.entity.attachments : [],
2268
+ };
2269
+ const overwriteTarget = overwrite === true
2270
+ ? canonicalAssetHelpers.resolveCanonicalOverwriteTarget(targetForOverwrite, safeFilename, taskAttachmentHelpers.describeTaskAttachment)
2271
+ : null;
2272
+ const canonicalAsset = overwriteTarget
2273
+ ? null
2274
+ : canonicalAssetHelpers.buildCanonicalWorkspaceAssetRelativePath({
2275
+ workspaceIdRaw: ACTIVE_WORKSPACE_ID,
2276
+ kind: inferAssetKind(safeFilename, inferMimeTypeFromPath(safeFilename)),
2277
+ filename: safeFilename
2278
+ });
2279
+ const destinationPath = overwriteTarget
2280
+ ? overwriteTarget.absolutePath
2281
+ : path.join(basePath, canonicalAsset.relativePath);
2282
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
2283
+ if (hasContent) {
2284
+ fs.writeFileSync(destinationPath, content, 'utf8');
2285
+ }
2286
+ else {
2287
+ const trimmedSource = sourcePath.trim();
2288
+ const sourceResolved = path.isAbsolute(trimmedSource)
2289
+ ? path.resolve(trimmedSource)
2290
+ : path.resolve(projectRoot, trimmedSource);
2291
+ if (!fs.existsSync(sourceResolved)) {
2292
+ throw new Error(`File not found: ${trimmedSource}`);
2293
+ }
2294
+ const sourceReal = fs.realpathSync(sourceResolved);
2295
+ const projectRootReal = fs.realpathSync(projectRoot);
2296
+ if (!isApprovedAttachmentSourcePath(sourceReal, projectRootReal)) {
2297
+ throw new Error("sourcePath must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root");
2298
+ }
2299
+ if (!fs.statSync(sourceReal).isFile()) {
2300
+ throw new Error(`Path is not a file: ${trimmedSource}`);
2301
+ }
2302
+ fs.copyFileSync(sourceReal, destinationPath);
2303
+ }
2304
+ const relativePath = overwriteTarget
2305
+ ? overwriteTarget.relativePath
2306
+ : toPosixPath(path.relative(projectRoot, destinationPath));
2307
+ if (relativePath.endsWith('.md')) {
2308
+ const fileContent = fs.readFileSync(destinationPath, 'utf8');
2309
+ const validation = validateStructuredDocForAttach(relativePath, fileContent);
2310
+ if (!validation.ok) {
2311
+ throw new Error(validation.message);
2312
+ }
2313
+ }
2314
+ const isMarkdownDocument = relativePath.toLowerCase().endsWith('.md') || relativePath.toLowerCase().endsWith('.markdown');
2315
+ const canonicalName = safeCaption || overwriteTarget?.asset.logicalName || safeFilename;
2316
+ const persistedCaption = isMarkdownDocument
2317
+ ? canonicalName
2318
+ : (safeCaption || overwriteTarget?.attachment.caption || safeFilename);
2319
+ const asset = await canonicalAssetHelpers.ensureCanonicalAttachment(relativePath, targetType, target.id, persistedCaption, now, 'attachment', {
2320
+ originalFilename: safeFilename,
2321
+ logicalName: isMarkdownDocument ? canonicalName : safeFilename,
2322
+ ...(overwriteTarget
2323
+ ? {
2324
+ assetId: overwriteTarget.asset.assetId,
2325
+ documentId: overwriteTarget.asset.documentId,
2326
+ createdAt: overwriteTarget.asset.createdAt,
2327
+ createdByUserId: overwriteTarget.asset.createdByUserId,
2328
+ scanStatus: overwriteTarget.asset.scanStatus,
2329
+ scanEngine: overwriteTarget.asset.scanEngine,
2330
+ scanDetails: overwriteTarget.asset.scanDetails,
2331
+ }
2332
+ : {})
2333
+ });
2334
+ const attachmentReferenceLabel = asset?.kind === 'image'
2335
+ ? getImageReferenceLabel(asset)
2336
+ : (asset && isValidCanonicalDocumentAsset(asset) ? getDocumentReferenceLabel(asset) : null);
2337
+ const attachment = {
2338
+ path: overwriteTarget?.attachment.path || (asset?.kind === 'document' && isValidCanonicalDocumentAsset(asset) && asset.assetId
2339
+ ? `/api/taskforce/documents/${encodeURIComponent(asset.assetId)}/content`
2340
+ : `/api/taskforce/context/${encodeURIComponent((canonicalAsset || { relativePath: overwriteTarget?.descriptor.fsPath || '' }).relativePath)}`),
2341
+ fsPath: overwriteTarget?.attachment.fsPath || canonicalAsset?.relativePath || overwriteTarget?.descriptor.fsPath,
2342
+ caption: isMarkdownDocument ? canonicalName : persistedCaption,
2343
+ displayName: isMarkdownDocument ? canonicalName : safeFilename,
2344
+ originalFilename: safeFilename,
2345
+ ...(asset?.assetId ? { assetId: asset.assetId } : {}),
2346
+ ...(typeof asset?.referenceNumber === 'number' ? { referenceNumber: asset.referenceNumber } : {}),
2347
+ ...(attachmentReferenceLabel ? { referenceLabel: attachmentReferenceLabel } : {}),
2348
+ timestamp: now
2349
+ };
2350
+ const existing = Array.isArray(target.entity.attachments)
2351
+ ? target.entity.attachments
2352
+ : [];
2353
+ if (overwriteTarget) {
2354
+ const updatedAttachments = existing.map((item, index) => (index === overwriteTarget.index
2355
+ ? attachment
2356
+ : item));
2357
+ updatePlanningAttachmentTarget(targetType, target.id, updatedAttachments);
2358
+ recordMcpWriteAudit(`save_${targetType}_attachment`, { [`${targetType}Id`]: target.id, path: attachment.fsPath, overwrite: true, attachmentId: asset?.assetId || overwriteTarget.descriptor.attachmentId });
2359
+ await notifyPlanningAttachmentMutation(targetType, target.id, target.entity);
2360
+ return {
2361
+ content: [{ type: "text", text: `${targetType[0].toUpperCase()}${targetType.slice(1)} attachment overwritten for ${formatPlanningAttachmentTarget(target)}\n${JSON.stringify(attachment, null, 2)}` }],
2362
+ };
2363
+ }
2364
+ const duplicate = existing.some((item) => {
2365
+ if (typeof item === 'string') {
2366
+ return item === attachment.path || item === attachment.fsPath;
2367
+ }
2368
+ if (!item || typeof item !== 'object')
2369
+ return false;
2370
+ if (item.path === attachment.path)
2371
+ return true;
2372
+ if (item.fsPath === attachment.fsPath)
2373
+ return true;
2374
+ return false;
2375
+ });
2376
+ if (duplicate) {
2377
+ return {
2378
+ content: [{ type: "text", text: `${targetType[0].toUpperCase()}${targetType.slice(1)} attachment saved for ${formatPlanningAttachmentTarget(target)}\n${JSON.stringify(attachment, null, 2)}\nAlready attached.` }],
2379
+ };
2380
+ }
2381
+ updatePlanningAttachmentTarget(targetType, target.id, [...existing, attachment]);
2382
+ recordMcpWriteAudit(`save_${targetType}_attachment`, { [`${targetType}Id`]: target.id, path: attachment.fsPath });
2383
+ await notifyPlanningAttachmentMutation(targetType, target.id, target.entity);
2384
+ return {
2385
+ content: [{ type: "text", text: `${targetType[0].toUpperCase()}${targetType.slice(1)} attachment saved and attached to ${formatPlanningAttachmentTarget(target)}\n${JSON.stringify(attachment, null, 2)}` }],
2386
+ };
2387
+ };
2388
+ const deletePlanningAttachment = async (targetType, args) => {
2389
+ const inputId = targetType === 'initiative' ? args.initiativeId : args.workstreamId;
2390
+ const { attachmentId } = args;
2391
+ if (!attachmentId || typeof attachmentId !== 'string')
2392
+ throw new Error('attachmentId is required');
2393
+ const target = resolvePlanningAttachmentTarget(targetType, inputId);
2394
+ const existing = Array.isArray(target.entity.attachments) ? target.entity.attachments : [];
2395
+ const descriptors = listPlanningAttachmentDescriptors(target);
2396
+ const targetIndex = descriptors.findIndex((entry) => entry.attachmentId === attachmentId);
2397
+ if (targetIndex < 0) {
2398
+ throw new Error(`Attachment ${attachmentId} not found on ${targetType} ${String(inputId || '').trim() || target.id}`);
2399
+ }
2400
+ const targetDescriptor = descriptors[targetIndex];
2401
+ const updatedAttachments = existing.filter((_, index) => index !== targetIndex);
2402
+ updatePlanningAttachmentTarget(targetType, target.id, updatedAttachments);
2403
+ let canonicalAssetDeleted = false;
2404
+ if (targetDescriptor.source === 'canonical' && targetDescriptor.assetId && workspaceAssetStore) {
2405
+ workspaceAssetStore.deleteLink({
2406
+ workspaceId: ACTIVE_WORKSPACE_ID,
2407
+ assetId: targetDescriptor.assetId,
2408
+ targetType,
2409
+ targetId: target.id,
2410
+ role: 'attachment',
2411
+ });
2412
+ const remainingLinks = workspaceAssetStore.listLinksForAsset(targetDescriptor.assetId, ACTIVE_WORKSPACE_ID);
2413
+ if (remainingLinks.length === 0) {
2414
+ workspaceAssetStore.markDeleted(targetDescriptor.assetId, ACTIVE_WORKSPACE_ID, new Date().toISOString(), null);
2415
+ canonicalAssetDeleted = true;
2416
+ }
2417
+ }
2418
+ recordMcpWriteAudit(`delete_${targetType}_attachment`, {
2419
+ [`${targetType}Id`]: target.id,
2420
+ attachmentId,
2421
+ canonicalAssetDeleted,
2422
+ });
2423
+ await notifyPlanningAttachmentMutation(targetType, target.id, target.entity);
2424
+ return {
2425
+ content: [{
2426
+ type: "text",
2427
+ text: JSON.stringify({
2428
+ success: true,
2429
+ [`${targetType}Id`]: target.id,
2430
+ attachmentId,
2431
+ canonicalAssetDeleted
2432
+ }, null, 2)
2433
+ }],
2434
+ };
2435
+ };
1898
2436
  const executeRegisteredTool = async (name, args) => {
1899
2437
  switch (name) {
1900
2438
  case "list_tasks": {
@@ -1950,6 +2488,15 @@ export function createTaskforceMcpServer(options = {}) {
1950
2488
  });
1951
2489
  activeAiProfileId = resolved.profile.id;
1952
2490
  activeAiBindingSource = 'session';
2491
+ if (auditActorType === 'oauth' && auditTokenId && resolved.profile.profileToken) {
2492
+ core.bindMcpOAuthConnectorGrantAiProfile({
2493
+ grantId: auditTokenId,
2494
+ workspaceId: ACTIVE_WORKSPACE_ID,
2495
+ profileId: resolved.profile.id,
2496
+ profileToken: resolved.profile.profileToken,
2497
+ profileName: resolved.profile.name,
2498
+ });
2499
+ }
1953
2500
  recordMcpWriteAudit('resolve_profile', {
1954
2501
  profileId: resolved.profile.id,
1955
2502
  created: resolved.created
@@ -2067,12 +2614,15 @@ export function createTaskforceMcpServer(options = {}) {
2067
2614
  };
2068
2615
  }
2069
2616
  case "create_initiative": {
2070
- const { title, description, ownerId, order } = args;
2617
+ const { title, description, ownerId, order, attachments } = args;
2618
+ if (attachments !== undefined && !Array.isArray(attachments))
2619
+ throw new Error('attachments must be an array');
2071
2620
  const created = core.createInitiative({
2072
2621
  title,
2073
2622
  description,
2074
2623
  ownerId: resolvePlanningOwnerInput(ownerId),
2075
2624
  order,
2625
+ ...(attachments !== undefined ? { attachments } : {}),
2076
2626
  }, ACTIVE_WORKSPACE_ID);
2077
2627
  recordMcpWriteAudit('create_initiative', { initiativeId: created.id });
2078
2628
  await notifyResourceMutation({
@@ -2080,11 +2630,11 @@ export function createTaskforceMcpServer(options = {}) {
2080
2630
  initiativeIds: [created.id],
2081
2631
  });
2082
2632
  return {
2083
- content: [{ type: "text", text: JSON.stringify(buildInitiativeResourcePayload(created), null, 2) }],
2633
+ content: [{ type: "text", text: JSON.stringify(buildPostWriteInitiativeResourcePayload(created, 'create_initiative:response'), null, 2) }],
2084
2634
  };
2085
2635
  }
2086
2636
  case "update_initiative": {
2087
- const { id, title, description, ownerId, order } = args;
2637
+ const { id, title, description, ownerId, order, attachments } = args;
2088
2638
  const { id: initiativeId } = resolveInitiativeByIdentifierOrThrow(id);
2089
2639
  const patch = {};
2090
2640
  if (title !== undefined)
@@ -2095,6 +2645,11 @@ export function createTaskforceMcpServer(options = {}) {
2095
2645
  patch.ownerId = resolvePlanningOwnerInput(ownerId);
2096
2646
  if (order !== undefined)
2097
2647
  patch.order = order;
2648
+ if (attachments !== undefined) {
2649
+ if (!Array.isArray(attachments))
2650
+ throw new Error('attachments must be an array');
2651
+ patch.attachments = attachments;
2652
+ }
2098
2653
  const updated = core.updateInitiative(initiativeId, patch, ACTIVE_WORKSPACE_ID);
2099
2654
  recordMcpWriteAudit('update_initiative', { initiativeId: updated.id, changedFields: Object.keys(patch) });
2100
2655
  await notifyResourceMutation({
@@ -2102,7 +2657,7 @@ export function createTaskforceMcpServer(options = {}) {
2102
2657
  initiativeIds: [updated.id],
2103
2658
  });
2104
2659
  return {
2105
- content: [{ type: "text", text: JSON.stringify(buildInitiativeResourcePayload(updated), null, 2) }],
2660
+ content: [{ type: "text", text: JSON.stringify(buildPostWriteInitiativeResourcePayload(updated, 'update_initiative:response'), null, 2) }],
2106
2661
  };
2107
2662
  }
2108
2663
  case "archive_initiative": {
@@ -2115,7 +2670,7 @@ export function createTaskforceMcpServer(options = {}) {
2115
2670
  initiativeIds: [archived.id],
2116
2671
  });
2117
2672
  return {
2118
- content: [{ type: "text", text: JSON.stringify(buildInitiativeResourcePayload(archived), null, 2) }],
2673
+ content: [{ type: "text", text: JSON.stringify(buildPostWriteInitiativeResourcePayload(archived, 'archive_initiative:response'), null, 2) }],
2119
2674
  };
2120
2675
  }
2121
2676
  case "unarchive_initiative": {
@@ -2128,7 +2683,7 @@ export function createTaskforceMcpServer(options = {}) {
2128
2683
  initiativeIds: [unarchived.id],
2129
2684
  });
2130
2685
  return {
2131
- content: [{ type: "text", text: JSON.stringify(buildInitiativeResourcePayload(unarchived), null, 2) }],
2686
+ content: [{ type: "text", text: JSON.stringify(buildPostWriteInitiativeResourcePayload(unarchived, 'unarchive_initiative:response'), null, 2) }],
2132
2687
  };
2133
2688
  }
2134
2689
  case "list_workstreams": {
@@ -2144,7 +2699,9 @@ export function createTaskforceMcpServer(options = {}) {
2144
2699
  };
2145
2700
  }
2146
2701
  case "create_workstream": {
2147
- const { title, description, ownerId, initiativeId, order } = args;
2702
+ const { title, description, ownerId, initiativeId, order, attachments } = args;
2703
+ if (attachments !== undefined && !Array.isArray(attachments))
2704
+ throw new Error('attachments must be an array');
2148
2705
  const initiative = initiativeId === undefined || String(initiativeId).trim() === ''
2149
2706
  ? null
2150
2707
  : resolveInitiativeByIdentifierOrThrow(initiativeId).id;
@@ -2154,6 +2711,7 @@ export function createTaskforceMcpServer(options = {}) {
2154
2711
  ownerId: resolvePlanningOwnerInput(ownerId),
2155
2712
  initiativeId: initiative,
2156
2713
  order,
2714
+ ...(attachments !== undefined ? { attachments } : {}),
2157
2715
  }, ACTIVE_WORKSPACE_ID);
2158
2716
  recordMcpWriteAudit('create_workstream', { workstreamId: created.id, initiativeId: created.initiativeId || null });
2159
2717
  await notifyResourceMutation({
@@ -2162,11 +2720,11 @@ export function createTaskforceMcpServer(options = {}) {
2162
2720
  ...(created.initiativeId ? { includeInitiatives: true, initiativeIds: [created.initiativeId] } : {}),
2163
2721
  });
2164
2722
  return {
2165
- content: [{ type: "text", text: JSON.stringify(buildWorkstreamResourcePayload(created), null, 2) }],
2723
+ content: [{ type: "text", text: JSON.stringify(buildPostWriteWorkstreamResourcePayload(created, 'create_workstream:response'), null, 2) }],
2166
2724
  };
2167
2725
  }
2168
2726
  case "update_workstream": {
2169
- const { id, title, description, ownerId, initiativeId, order } = args;
2727
+ const { id, title, description, ownerId, initiativeId, order, attachments } = args;
2170
2728
  const { id: workstreamId, workstream: existingWorkstream } = resolveWorkstreamByIdentifierOrThrow(id);
2171
2729
  const patch = {};
2172
2730
  if (title !== undefined)
@@ -2177,6 +2735,11 @@ export function createTaskforceMcpServer(options = {}) {
2177
2735
  patch.ownerId = resolvePlanningOwnerInput(ownerId);
2178
2736
  if (order !== undefined)
2179
2737
  patch.order = order;
2738
+ if (attachments !== undefined) {
2739
+ if (!Array.isArray(attachments))
2740
+ throw new Error('attachments must be an array');
2741
+ patch.attachments = attachments;
2742
+ }
2180
2743
  if (initiativeId !== undefined) {
2181
2744
  patch.initiativeId = String(initiativeId).trim() === ''
2182
2745
  ? null
@@ -2195,7 +2758,7 @@ export function createTaskforceMcpServer(options = {}) {
2195
2758
  ...(relatedInitiativeIds.size > 0 ? { includeInitiatives: true, initiativeIds: Array.from(relatedInitiativeIds) } : {}),
2196
2759
  });
2197
2760
  return {
2198
- content: [{ type: "text", text: JSON.stringify(buildWorkstreamResourcePayload(updated), null, 2) }],
2761
+ content: [{ type: "text", text: JSON.stringify(buildPostWriteWorkstreamResourcePayload(updated, 'update_workstream:response'), null, 2) }],
2199
2762
  };
2200
2763
  }
2201
2764
  case "archive_workstream": {
@@ -2209,7 +2772,7 @@ export function createTaskforceMcpServer(options = {}) {
2209
2772
  ...(existingWorkstream.initiativeId ? { includeInitiatives: true, initiativeIds: [existingWorkstream.initiativeId] } : {}),
2210
2773
  });
2211
2774
  return {
2212
- content: [{ type: "text", text: JSON.stringify(buildWorkstreamResourcePayload(archived), null, 2) }],
2775
+ content: [{ type: "text", text: JSON.stringify(buildPostWriteWorkstreamResourcePayload(archived, 'archive_workstream:response'), null, 2) }],
2213
2776
  };
2214
2777
  }
2215
2778
  case "unarchive_workstream": {
@@ -2223,7 +2786,7 @@ export function createTaskforceMcpServer(options = {}) {
2223
2786
  ...(existingWorkstream.initiativeId ? { includeInitiatives: true, initiativeIds: [existingWorkstream.initiativeId] } : {}),
2224
2787
  });
2225
2788
  return {
2226
- content: [{ type: "text", text: JSON.stringify(buildWorkstreamResourcePayload(unarchived), null, 2) }],
2789
+ content: [{ type: "text", text: JSON.stringify(buildPostWriteWorkstreamResourcePayload(unarchived, 'unarchive_workstream:response'), null, 2) }],
2227
2790
  };
2228
2791
  }
2229
2792
  case "set_task_workstream": {
@@ -2345,10 +2908,11 @@ export function createTaskforceMcpServer(options = {}) {
2345
2908
  };
2346
2909
  }
2347
2910
  case "get_task_attachments": {
2348
- const { id } = args;
2349
- const task = core.resolveTaskIdentifier(id, ACTIVE_WORKSPACE_ID);
2911
+ const { id, taskId } = args;
2912
+ const taskIdentifier = taskId ?? id;
2913
+ const task = core.resolveTaskIdentifier(taskIdentifier, ACTIVE_WORKSPACE_ID);
2350
2914
  if (!task) {
2351
- throw new Error(`Task ${id} not found`);
2915
+ throw new Error(`Task ${taskIdentifier} not found`);
2352
2916
  }
2353
2917
  return {
2354
2918
  content: [{
@@ -2431,6 +2995,32 @@ export function createTaskforceMcpServer(options = {}) {
2431
2995
  }],
2432
2996
  };
2433
2997
  }
2998
+ case "get_workstream_attachments": {
2999
+ const { id } = args;
3000
+ const target = resolvePlanningAttachmentTarget('workstream', id);
3001
+ return {
3002
+ content: [{
3003
+ type: "text",
3004
+ text: JSON.stringify(buildPlanningAttachmentListPayload('workstream', target), null, 2)
3005
+ }],
3006
+ };
3007
+ }
3008
+ case "read_workstream_attachment": {
3009
+ return readPlanningAttachment('workstream', args);
3010
+ }
3011
+ case "get_initiative_attachments": {
3012
+ const { id } = args;
3013
+ const target = resolvePlanningAttachmentTarget('initiative', id);
3014
+ return {
3015
+ content: [{
3016
+ type: "text",
3017
+ text: JSON.stringify(buildPlanningAttachmentListPayload('initiative', target), null, 2)
3018
+ }],
3019
+ };
3020
+ }
3021
+ case "read_initiative_attachment": {
3022
+ return readPlanningAttachment('initiative', args);
3023
+ }
2434
3024
  case "search_images": {
2435
3025
  assertAnnotatedAttachmentReadAccess();
2436
3026
  const { query, limit } = args;
@@ -2459,8 +3049,8 @@ export function createTaskforceMcpServer(options = {}) {
2459
3049
  }
2460
3050
  case "get_image": {
2461
3051
  assertAnnotatedAttachmentReadAccess();
2462
- const { imageId } = args;
2463
- const image = resolveImageAssetByIdentifierOrThrow(imageId);
3052
+ const { id, imageId } = args;
3053
+ const image = resolveImageAssetByIdentifierOrThrow(imageId ?? id);
2464
3054
  const sessionsForImage = core.listAnnotatedAttachmentSessions({
2465
3055
  workspaceId: ACTIVE_WORKSPACE_ID,
2466
3056
  imageAssetId: image.assetId,
@@ -2544,8 +3134,8 @@ export function createTaskforceMcpServer(options = {}) {
2544
3134
  };
2545
3135
  }
2546
3136
  case "get_document": {
2547
- const { documentId } = args;
2548
- const asset = resolveDocumentAssetByIdentifierOrThrow(documentId);
3137
+ const { id, documentId } = args;
3138
+ const asset = resolveDocumentAssetByIdentifierOrThrow(documentId ?? id);
2549
3139
  const linkedTasks = (workspaceAssetStore?.listLinksForAsset(asset.assetId, ACTIVE_WORKSPACE_ID) || [])
2550
3140
  .filter((entry) => !entry.deletedAt)
2551
3141
  .filter((entry) => entry.targetType === 'task' && !!entry.taskId)
@@ -3070,9 +3660,13 @@ export function createTaskforceMcpServer(options = {}) {
3070
3660
  };
3071
3661
  }
3072
3662
  case "review_document": {
3073
- const { documentId, sessionId, title, comments } = args;
3074
- const asset = resolveDocumentAssetByIdentifierOrThrow(documentId);
3075
- const normalizedComments = Array.isArray(comments) ? comments : [];
3663
+ const { id, documentId, sessionId, title, comments, prompt } = args;
3664
+ const asset = resolveDocumentAssetByIdentifierOrThrow(documentId ?? id);
3665
+ const normalizedComments = Array.isArray(comments)
3666
+ ? comments
3667
+ : (typeof prompt === 'string' && prompt.trim()
3668
+ ? [{ body: prompt.trim() }]
3669
+ : []);
3076
3670
  if (normalizedComments.length === 0) {
3077
3671
  throw new Error('comments array is required');
3078
3672
  }
@@ -3788,11 +4382,12 @@ export function createTaskforceMcpServer(options = {}) {
3788
4382
  if (!validation.ok) {
3789
4383
  throw new Error(validation.message);
3790
4384
  }
3791
- const updated = core.addComment(id, text, author, ACTIVE_WORKSPACE_ID);
4385
+ const effectiveAuthor = resolveMcpCommentAuthorInput(author);
4386
+ const updated = core.addComment(id, text, effectiveAuthor, ACTIVE_WORKSPACE_ID, { actorRef: effectiveAuthor });
3792
4387
  if (!updated) {
3793
4388
  throw new Error(`Task ${id} not found`);
3794
4389
  }
3795
- recordMcpWriteAudit('add_comment', { taskId: id, author: author || 'ai' });
4390
+ recordMcpWriteAudit('add_comment', { taskId: id, author: effectiveAuthor });
3796
4391
  await notifyResourceMutation({
3797
4392
  taskIds: [id],
3798
4393
  includeTasks: true,
@@ -3845,7 +4440,7 @@ export function createTaskforceMcpServer(options = {}) {
3845
4440
  }
3846
4441
  const updated = core.updateTask(id, {
3847
4442
  attachments: [...existing, attachment]
3848
- }, ACTIVE_WORKSPACE_ID);
4443
+ }, ACTIVE_WORKSPACE_ID, { actorRef: getActiveMcpActorRef() });
3849
4444
  if (!updated) {
3850
4445
  throw new Error(`Task ${id} not found`);
3851
4446
  }
@@ -3859,97 +4454,319 @@ export function createTaskforceMcpServer(options = {}) {
3859
4454
  };
3860
4455
  }
3861
4456
  case "save_task_attachment": {
3862
- const { id: inputId, filename, content, sourcePath, caption, overwrite = false } = args;
4457
+ const { id: inputId, filename, content, sourcePath, uploadedPath, uploadDraftId, mimeType, sizeBytes, caption, overwrite = false } = args;
3863
4458
  const { id, task } = resolveTaskByIdentifierOrThrow(inputId);
3864
4459
  if (!filename || typeof filename !== 'string')
3865
4460
  throw new Error("filename is required");
3866
4461
  const hasContent = typeof content === 'string';
3867
4462
  const hasSourcePath = typeof sourcePath === 'string' && sourcePath.trim().length > 0;
3868
- if (hasContent === hasSourcePath) {
3869
- throw new Error("Provide exactly one of: content or sourcePath");
3870
- }
4463
+ const hasUploadedPath = typeof uploadedPath === 'string' && uploadedPath.trim().length > 0;
4464
+ const contentModeCount = Number(hasContent) + Number(hasSourcePath) + Number(hasUploadedPath);
3871
4465
  const now = new Date().toISOString();
3872
4466
  const safeCaption = typeof caption === 'string' && caption.trim().length > 0
3873
4467
  ? caption.trim()
3874
4468
  : undefined;
3875
4469
  const safeFilename = sanitizeFilenameForPath(filename);
4470
+ const safeMimeType = typeof mimeType === 'string' && mimeType.trim().length > 0
4471
+ ? mimeType.trim().toLowerCase()
4472
+ : '';
4473
+ const numericSizeBytes = Number(sizeBytes);
4474
+ const hasStagedInitMetadata = !!safeMimeType && Number.isFinite(numericSizeBytes) && numericSizeBytes > 0;
3876
4475
  const { projectRoot, basePath } = core.getPaths();
3877
4476
  const overwriteTarget = overwrite === true
3878
4477
  ? canonicalAssetHelpers.resolveCanonicalOverwriteTarget(task, safeFilename, taskAttachmentHelpers.describeTaskAttachment)
3879
4478
  : null;
3880
- const canonicalAsset = overwriteTarget
3881
- ? null
3882
- : canonicalAssetHelpers.buildCanonicalWorkspaceAssetRelativePath({
4479
+ const buildAttachmentRecord = (asset, storageKey) => {
4480
+ const isMarkdownDocument = storageKey.toLowerCase().endsWith('.md') || storageKey.toLowerCase().endsWith('.markdown');
4481
+ const canonicalName = safeCaption || overwriteTarget?.asset.logicalName || safeFilename;
4482
+ const persistedCaption = isMarkdownDocument
4483
+ ? canonicalName
4484
+ : (safeCaption || overwriteTarget?.attachment.caption || safeFilename);
4485
+ return {
4486
+ path: overwriteTarget?.attachment.path || (asset?.kind === 'document' && isValidCanonicalDocumentAsset(asset) && asset.assetId
4487
+ ? `/api/taskforce/documents/${encodeURIComponent(asset.assetId)}/content`
4488
+ : `/api/taskforce/context/${encodeURIComponent(storageKey)}`),
4489
+ fsPath: overwriteTarget?.attachment.fsPath || storageKey,
4490
+ caption: isMarkdownDocument ? canonicalName : persistedCaption,
4491
+ displayName: isMarkdownDocument ? canonicalName : safeFilename,
4492
+ originalFilename: safeFilename,
4493
+ ...(asset?.assetId ? { assetId: asset.assetId } : {}),
4494
+ timestamp: now
4495
+ };
4496
+ };
4497
+ if (contentModeCount === 0) {
4498
+ if (!hasStagedInitMetadata) {
4499
+ throw new Error('Provide exactly one of: content, sourcePath, or uploadedPath. To start a staged remote upload, provide mimeType and sizeBytes.');
4500
+ }
4501
+ if (runtimeMode !== 'cloud' || !objectStorageClient || objectStorageConfig.provider !== 'r2' || !objectStorageConfig.r2) {
4502
+ throw new Error('Staged remote uploads require cloud runtime with object storage enabled. Otherwise provide content or sourcePath.');
4503
+ }
4504
+ const stagedAsset = canonicalAssetHelpers.buildCanonicalWorkspaceAssetRelativePath({
3883
4505
  workspaceIdRaw: ACTIVE_WORKSPACE_ID,
3884
- kind: inferAssetKind(safeFilename, inferMimeTypeFromPath(safeFilename)),
4506
+ kind: inferAssetKind(safeFilename, safeMimeType),
3885
4507
  filename: safeFilename
3886
4508
  });
3887
- const destinationPath = overwriteTarget
3888
- ? overwriteTarget.absolutePath
3889
- : path.join(basePath, canonicalAsset.relativePath);
3890
- fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
3891
- if (hasContent) {
3892
- fs.writeFileSync(destinationPath, content, 'utf8');
4509
+ const uploadDraftId = `task-attachment-draft-${randomUUID().replace(/-/g, '')}`;
4510
+ const uploadExpiresInSeconds = Math.max(1, Math.floor(objectStorageConfig.r2.signedUploadTtlSeconds));
4511
+ const uploadExpiresAt = new Date(Date.now() + uploadExpiresInSeconds * 1000).toISOString();
4512
+ core.createTaskAttachmentUploadDraft({
4513
+ draftId: uploadDraftId,
4514
+ workspaceId: ACTIVE_WORKSPACE_ID,
4515
+ taskId: id,
4516
+ actorRef: getActiveMcpActorRef(),
4517
+ storageKey: stagedAsset.relativePath,
4518
+ mimeType: safeMimeType,
4519
+ originalFilename: safeFilename,
4520
+ sizeBytes: Math.floor(numericSizeBytes),
4521
+ storageProvider: 'r2',
4522
+ });
4523
+ const uploadUrl = objectStorageClient.createSignedPutUrl(stagedAsset.relativePath, uploadExpiresInSeconds, safeMimeType);
4524
+ return {
4525
+ content: [{
4526
+ type: "text",
4527
+ text: JSON.stringify({
4528
+ success: true,
4529
+ mode: 'staged_upload',
4530
+ status: 'upload_required',
4531
+ taskId: id,
4532
+ referenceLabel: formatTaskIdentifier(task),
4533
+ filename: safeFilename,
4534
+ mimeType: safeMimeType,
4535
+ sizeBytes: Math.floor(numericSizeBytes),
4536
+ assetId: stagedAsset.assetId,
4537
+ uploadDraftId,
4538
+ expiresAt: uploadExpiresAt,
4539
+ upload: {
4540
+ uploadUrl,
4541
+ method: 'PUT',
4542
+ headers: {
4543
+ 'Content-Type': safeMimeType
4544
+ },
4545
+ uploadedPath: stagedAsset.relativePath,
4546
+ },
4547
+ finalize: {
4548
+ tool: 'save_task_attachment',
4549
+ arguments: {
4550
+ id: inputId,
4551
+ filename: safeFilename,
4552
+ uploadDraftId,
4553
+ mimeType: safeMimeType,
4554
+ uploadedPath: stagedAsset.relativePath,
4555
+ ...(safeCaption ? { caption: safeCaption } : {}),
4556
+ ...(overwrite === true ? { overwrite: true } : {}),
4557
+ }
4558
+ },
4559
+ guidance: 'Upload the raw file bytes to upload.uploadUrl with the listed headers before expiresAt, then call save_task_attachment again with finalize.arguments to attach the uploaded file to the task.'
4560
+ }, null, 2)
4561
+ }],
4562
+ };
3893
4563
  }
3894
- else {
3895
- const trimmedSource = sourcePath.trim();
3896
- const sourceResolved = path.isAbsolute(trimmedSource)
3897
- ? path.resolve(trimmedSource)
3898
- : path.resolve(projectRoot, trimmedSource);
3899
- if (!fs.existsSync(sourceResolved)) {
3900
- throw new Error(`File not found: ${trimmedSource}`);
4564
+ if (contentModeCount > 1) {
4565
+ throw new Error('Provide exactly one of: content, sourcePath, or uploadedPath');
4566
+ }
4567
+ let attachment;
4568
+ let asset = null;
4569
+ let storageKeyForAudit = '';
4570
+ let replacedStorageKey = null;
4571
+ if (hasUploadedPath) {
4572
+ if (!objectStorageClient || objectStorageConfig.provider !== 'r2') {
4573
+ throw new Error('uploadedPath requires object storage to be enabled for this MCP runtime.');
3901
4574
  }
3902
- const sourceReal = fs.realpathSync(sourceResolved);
3903
- const projectRootReal = fs.realpathSync(projectRoot);
3904
- if (!isApprovedAttachmentSourcePath(sourceReal, projectRootReal)) {
3905
- throw new Error("sourcePath must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root");
4575
+ if (!workspaceAssetStore) {
4576
+ throw new Error('uploadedPath requires canonical asset storage support.');
3906
4577
  }
3907
- if (!fs.statSync(sourceReal).isFile()) {
3908
- throw new Error(`Path is not a file: ${trimmedSource}`);
4578
+ const normalizedUploadDraftId = typeof uploadDraftId === 'string' ? uploadDraftId.trim() : '';
4579
+ if (!normalizedUploadDraftId) {
4580
+ throw new Error('uploadedPath finalization requires uploadDraftId from the staged upload init response.');
3909
4581
  }
3910
- fs.copyFileSync(sourceReal, destinationPath);
3911
- }
3912
- const relativePath = overwriteTarget
3913
- ? overwriteTarget.relativePath
3914
- : toPosixPath(path.relative(projectRoot, destinationPath));
3915
- if (relativePath.endsWith('.md')) {
3916
- const fileContent = fs.readFileSync(destinationPath, 'utf8');
3917
- const validation = validateStructuredDocForAttach(relativePath, fileContent);
3918
- if (!validation.ok) {
3919
- throw new Error(validation.message);
4582
+ const draft = core.getTaskAttachmentUploadDraft(normalizedUploadDraftId);
4583
+ if (!draft || draft.discardedAt || draft.consumedAt) {
4584
+ throw new Error('uploadDraftId is invalid, expired, or already consumed.');
4585
+ }
4586
+ if (draft.workspaceId !== ACTIVE_WORKSPACE_ID) {
4587
+ throw new Error('uploadDraftId does not belong to the active workspace.');
4588
+ }
4589
+ if (draft.taskId !== id) {
4590
+ throw new Error('uploadDraftId does not belong to this task.');
4591
+ }
4592
+ if (draft.actorRef !== getActiveMcpActorRef()) {
4593
+ throw new Error('uploadDraftId was created by a different actor and cannot finalize this attachment.');
4594
+ }
4595
+ const normalizedUploadedPath = normalizeTaskDataRelativePath(uploadedPath.trim());
4596
+ if (!normalizedUploadedPath) {
4597
+ throw new Error('uploadedPath must be a valid Taskforce storage path.');
4598
+ }
4599
+ if (draft.storageKey !== normalizedUploadedPath) {
4600
+ throw new Error('uploadedPath does not match the staged upload draft.');
3920
4601
  }
4602
+ if (inferWorkspaceIdFromStoragePath(normalizedUploadedPath) !== ACTIVE_WORKSPACE_ID) {
4603
+ throw new Error('uploadedPath does not belong to the active workspace.');
4604
+ }
4605
+ const uploadTtlSeconds = objectStorageConfig.provider === 'r2' && objectStorageConfig.r2
4606
+ ? Math.max(1, Math.floor(objectStorageConfig.r2.signedUploadTtlSeconds))
4607
+ : 0;
4608
+ if (uploadTtlSeconds > 0) {
4609
+ const draftCreatedAtMs = Date.parse(draft.createdAt);
4610
+ if (Number.isFinite(draftCreatedAtMs) && Date.now() > draftCreatedAtMs + uploadTtlSeconds * 1000) {
4611
+ core.discardTaskAttachmentUploadDraft(draft.draftId);
4612
+ await objectStorageClient.deleteObject(normalizedUploadedPath).catch(() => { });
4613
+ throw new Error('uploadDraftId has expired. Start a new staged upload.');
4614
+ }
4615
+ }
4616
+ const uploadedObject = await objectStorageClient.getObject(normalizedUploadedPath);
4617
+ if (!uploadedObject) {
4618
+ core.discardTaskAttachmentUploadDraft(draft.draftId);
4619
+ throw new Error(`Uploaded file not found: ${normalizedUploadedPath}`);
4620
+ }
4621
+ if (safeFilename !== draft.originalFilename) {
4622
+ throw new Error('filename does not match the staged upload draft.');
4623
+ }
4624
+ const draftMimeType = String(draft.mimeType || '').trim().toLowerCase() || 'application/octet-stream';
4625
+ if (safeMimeType && safeMimeType !== draftMimeType) {
4626
+ throw new Error('mimeType does not match the staged upload draft.');
4627
+ }
4628
+ const uploadedContentType = String(uploadedObject.contentType || '').trim().toLowerCase();
4629
+ if (uploadedContentType && uploadedContentType !== draftMimeType) {
4630
+ throw new Error('Uploaded object content type does not match the staged upload draft.');
4631
+ }
4632
+ if (uploadedObject.body.length !== draft.sizeBytes) {
4633
+ throw new Error('Uploaded object size does not match the staged upload draft.');
4634
+ }
4635
+ const resolvedMimeType = draftMimeType || uploadedContentType || inferMimeTypeFromPath(safeFilename);
4636
+ if (normalizedUploadedPath.endsWith('.md') || normalizedUploadedPath.endsWith('.markdown')) {
4637
+ const validation = validateStructuredDocForAttach(normalizedUploadedPath, uploadedObject.body.toString('utf8'));
4638
+ if (!validation.ok) {
4639
+ throw new Error(validation.message);
4640
+ }
4641
+ }
4642
+ const virusScanConfig = resolveVirusScanConfigFromEnv(process.env);
4643
+ const scan = await scanBufferForThreats(uploadedObject.body, virusScanConfig);
4644
+ if (scan.verdict === 'infected') {
4645
+ await objectStorageClient.deleteObject(normalizedUploadedPath).catch(() => { });
4646
+ throw new Error('File rejected by virus scanner.');
4647
+ }
4648
+ if (scan.verdict === 'error' && virusScanConfig.failClosed) {
4649
+ throw new Error('Virus scan failed. Upload blocked.');
4650
+ }
4651
+ const kind = inferAssetKind(safeFilename, resolvedMimeType);
4652
+ const embeddedAssetId = normalizedUploadedPath.match(/(^|\/)(asset-[a-f0-9]{32})(?=[^/]*$)/i)?.[2] || null;
4653
+ const existingUploadedAsset = workspaceAssetStore.getByStorageKey(normalizedUploadedPath, ACTIVE_WORKSPACE_ID);
4654
+ const isMarkdownDocument = normalizedUploadedPath.toLowerCase().endsWith('.md') || normalizedUploadedPath.toLowerCase().endsWith('.markdown');
4655
+ const canonicalName = safeCaption || overwriteTarget?.asset.logicalName || safeFilename;
4656
+ const persistedCaption = isMarkdownDocument
4657
+ ? canonicalName
4658
+ : (safeCaption || overwriteTarget?.attachment.caption || safeFilename);
4659
+ replacedStorageKey = overwriteTarget?.asset.storageKey && overwriteTarget.asset.storageKey !== normalizedUploadedPath
4660
+ ? overwriteTarget.asset.storageKey
4661
+ : null;
4662
+ asset = workspaceAssetStore.upsertAsset({
4663
+ assetId: overwriteTarget?.asset.assetId || existingUploadedAsset?.assetId || embeddedAssetId || `asset-${randomUUID().replace(/-/g, '')}`,
4664
+ workspaceId: ACTIVE_WORKSPACE_ID,
4665
+ kind,
4666
+ logicalName: isMarkdownDocument ? canonicalName : safeFilename,
4667
+ originalFilename: safeFilename,
4668
+ mimeType: resolvedMimeType,
4669
+ storageProvider: 'r2',
4670
+ storageKey: normalizedUploadedPath,
4671
+ contentSha256: createHash('sha256').update(uploadedObject.body).digest('hex'),
4672
+ sizeBytes: uploadedObject.body.length,
4673
+ documentId: kind === 'document'
4674
+ ? (overwriteTarget?.asset.documentId || existingUploadedAsset?.documentId || `doc-${randomUUID().replace(/-/g, '')}`)
4675
+ : null,
4676
+ version: kind === 'document'
4677
+ ? Math.max(1, Number(overwriteTarget?.asset.version || existingUploadedAsset?.version || 0) + 1)
4678
+ : null,
4679
+ isLatest: true,
4680
+ scanStatus: scanVerdictToCanonicalStatus(scan.verdict),
4681
+ scanEngine: scan.engine || null,
4682
+ scanDetails: scan.details || null,
4683
+ createdByUserId: overwriteTarget?.asset.createdByUserId || getActiveMcpActorRef(),
4684
+ createdAt: overwriteTarget?.asset.createdAt || existingUploadedAsset?.createdAt || now,
4685
+ updatedAt: now,
4686
+ deletedAt: null,
4687
+ purgeAfter: null,
4688
+ });
4689
+ workspaceAssetStore.upsertLink({
4690
+ workspaceId: ACTIVE_WORKSPACE_ID,
4691
+ assetId: asset.assetId,
4692
+ taskId: id,
4693
+ role: 'attachment',
4694
+ displayName: kind === 'document' ? null : persistedCaption,
4695
+ updatedAt: now,
4696
+ deletedAt: null,
4697
+ });
4698
+ core.markTaskAttachmentUploadDraftConsumed(draft.draftId);
4699
+ attachment = buildAttachmentRecord(asset, normalizedUploadedPath);
4700
+ storageKeyForAudit = normalizedUploadedPath;
3921
4701
  }
3922
- const isMarkdownDocument = relativePath.toLowerCase().endsWith('.md') || relativePath.toLowerCase().endsWith('.markdown');
3923
- const canonicalName = safeCaption || overwriteTarget?.asset.logicalName || safeFilename;
3924
- const persistedCaption = isMarkdownDocument
3925
- ? canonicalName
3926
- : (safeCaption || overwriteTarget?.attachment.caption || safeFilename);
3927
- const asset = canonicalAssetHelpers.ensureCanonicalTaskAttachment(relativePath, id, persistedCaption, now, 'attachment', {
3928
- originalFilename: safeFilename,
3929
- logicalName: isMarkdownDocument ? canonicalName : safeFilename,
3930
- ...(overwriteTarget
3931
- ? {
3932
- assetId: overwriteTarget.asset.assetId,
3933
- documentId: overwriteTarget.asset.documentId,
3934
- createdAt: overwriteTarget.asset.createdAt,
3935
- createdByUserId: overwriteTarget.asset.createdByUserId,
3936
- scanStatus: overwriteTarget.asset.scanStatus,
3937
- scanEngine: overwriteTarget.asset.scanEngine,
3938
- scanDetails: overwriteTarget.asset.scanDetails,
4702
+ else {
4703
+ const canonicalAsset = overwriteTarget
4704
+ ? null
4705
+ : canonicalAssetHelpers.buildCanonicalWorkspaceAssetRelativePath({
4706
+ workspaceIdRaw: ACTIVE_WORKSPACE_ID,
4707
+ kind: inferAssetKind(safeFilename, inferMimeTypeFromPath(safeFilename)),
4708
+ filename: safeFilename
4709
+ });
4710
+ const destinationPath = overwriteTarget
4711
+ ? overwriteTarget.absolutePath
4712
+ : path.join(basePath, canonicalAsset.relativePath);
4713
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
4714
+ if (hasContent) {
4715
+ fs.writeFileSync(destinationPath, content, 'utf8');
4716
+ }
4717
+ else {
4718
+ const trimmedSource = sourcePath.trim();
4719
+ const sourceResolved = path.isAbsolute(trimmedSource)
4720
+ ? path.resolve(trimmedSource)
4721
+ : path.resolve(projectRoot, trimmedSource);
4722
+ if (!fs.existsSync(sourceResolved)) {
4723
+ throw new Error(`File not found: ${trimmedSource}`);
3939
4724
  }
3940
- : {})
3941
- });
3942
- const attachment = {
3943
- path: overwriteTarget?.attachment.path || (asset?.kind === 'document' && isValidCanonicalDocumentAsset(asset) && asset.assetId
3944
- ? `/api/taskforce/documents/${encodeURIComponent(asset.assetId)}/content`
3945
- : `/api/taskforce/context/${encodeURIComponent((canonicalAsset || { relativePath: overwriteTarget?.descriptor.fsPath || '' }).relativePath)}`),
3946
- fsPath: overwriteTarget?.attachment.fsPath || canonicalAsset?.relativePath || overwriteTarget?.descriptor.fsPath,
3947
- caption: isMarkdownDocument ? canonicalName : persistedCaption,
3948
- displayName: isMarkdownDocument ? canonicalName : safeFilename,
3949
- originalFilename: safeFilename,
3950
- ...(asset?.assetId ? { assetId: asset.assetId } : {}),
3951
- timestamp: now
3952
- };
4725
+ const sourceReal = fs.realpathSync(sourceResolved);
4726
+ const projectRootReal = fs.realpathSync(projectRoot);
4727
+ if (!isApprovedAttachmentSourcePath(sourceReal, projectRootReal)) {
4728
+ throw new Error("sourcePath must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root");
4729
+ }
4730
+ if (!fs.statSync(sourceReal).isFile()) {
4731
+ throw new Error(`Path is not a file: ${trimmedSource}`);
4732
+ }
4733
+ fs.copyFileSync(sourceReal, destinationPath);
4734
+ }
4735
+ const relativePath = overwriteTarget
4736
+ ? overwriteTarget.relativePath
4737
+ : toPosixPath(path.relative(projectRoot, destinationPath));
4738
+ if (relativePath.endsWith('.md')) {
4739
+ const fileContent = fs.readFileSync(destinationPath, 'utf8');
4740
+ const validation = validateStructuredDocForAttach(relativePath, fileContent);
4741
+ if (!validation.ok) {
4742
+ throw new Error(validation.message);
4743
+ }
4744
+ }
4745
+ const isMarkdownDocument = relativePath.toLowerCase().endsWith('.md') || relativePath.toLowerCase().endsWith('.markdown');
4746
+ const canonicalName = safeCaption || overwriteTarget?.asset.logicalName || safeFilename;
4747
+ const persistedCaption = isMarkdownDocument
4748
+ ? canonicalName
4749
+ : (safeCaption || overwriteTarget?.attachment.caption || safeFilename);
4750
+ asset = await canonicalAssetHelpers.ensureCanonicalTaskAttachment(relativePath, id, persistedCaption, now, 'attachment', {
4751
+ originalFilename: safeFilename,
4752
+ logicalName: isMarkdownDocument ? canonicalName : safeFilename,
4753
+ ...(overwriteTarget
4754
+ ? {
4755
+ assetId: overwriteTarget.asset.assetId,
4756
+ documentId: overwriteTarget.asset.documentId,
4757
+ createdAt: overwriteTarget.asset.createdAt,
4758
+ createdByUserId: overwriteTarget.asset.createdByUserId,
4759
+ scanStatus: overwriteTarget.asset.scanStatus,
4760
+ scanEngine: overwriteTarget.asset.scanEngine,
4761
+ scanDetails: overwriteTarget.asset.scanDetails,
4762
+ }
4763
+ : {
4764
+ createdByUserId: getActiveMcpActorRef(),
4765
+ })
4766
+ });
4767
+ attachment = buildAttachmentRecord(asset, canonicalAsset?.relativePath || overwriteTarget?.descriptor.fsPath || relativePath);
4768
+ storageKeyForAudit = String(attachment.fsPath || '');
4769
+ }
3953
4770
  const existing = Array.isArray(task.attachments)
3954
4771
  ? task.attachments
3955
4772
  : [];
@@ -3959,11 +4776,22 @@ export function createTaskforceMcpServer(options = {}) {
3959
4776
  : item));
3960
4777
  const updated = core.updateTask(id, {
3961
4778
  attachments: updatedAttachments
3962
- }, ACTIVE_WORKSPACE_ID);
4779
+ }, ACTIVE_WORKSPACE_ID, { actorRef: getActiveMcpActorRef() });
3963
4780
  if (!updated) {
3964
4781
  throw new Error(`Task ${id} not found`);
3965
4782
  }
3966
- recordMcpWriteAudit('save_task_attachment', { taskId: id, path: attachment.fsPath, overwrite: true, attachmentId: asset?.assetId || overwriteTarget.descriptor.attachmentId });
4783
+ if (replacedStorageKey) {
4784
+ if (overwriteTarget.asset.storageProvider === 'r2' && objectStorageClient) {
4785
+ await objectStorageClient.deleteObject(replacedStorageKey).catch(() => { });
4786
+ }
4787
+ else if (overwriteTarget.asset.storageProvider === 'local') {
4788
+ const replacedAbsolutePath = path.resolve(basePath, replacedStorageKey);
4789
+ if (replacedAbsolutePath.startsWith(path.resolve(basePath)) && fs.existsSync(replacedAbsolutePath)) {
4790
+ fs.rmSync(replacedAbsolutePath, { force: true });
4791
+ }
4792
+ }
4793
+ }
4794
+ recordMcpWriteAudit('save_task_attachment', { taskId: id, path: storageKeyForAudit, overwrite: true, attachmentId: asset?.assetId || overwriteTarget.descriptor.attachmentId });
3967
4795
  await notifyResourceMutation({
3968
4796
  taskIds: [id],
3969
4797
  includeTasks: true,
@@ -3991,11 +4819,11 @@ export function createTaskforceMcpServer(options = {}) {
3991
4819
  }
3992
4820
  const updated = core.updateTask(id, {
3993
4821
  attachments: [...existing, attachment]
3994
- }, ACTIVE_WORKSPACE_ID);
4822
+ }, ACTIVE_WORKSPACE_ID, { actorRef: getActiveMcpActorRef() });
3995
4823
  if (!updated) {
3996
4824
  throw new Error(`Task ${id} not found`);
3997
4825
  }
3998
- recordMcpWriteAudit('save_task_attachment', { taskId: id, path: attachment.fsPath });
4826
+ recordMcpWriteAudit('save_task_attachment', { taskId: id, path: storageKeyForAudit });
3999
4827
  await notifyResourceMutation({
4000
4828
  taskIds: [id],
4001
4829
  includeTasks: true,
@@ -4004,6 +4832,12 @@ export function createTaskforceMcpServer(options = {}) {
4004
4832
  content: [{ type: "text", text: `Task attachment saved and attached to ${formatTaskIdentifier(task)}\n${JSON.stringify(attachment, null, 2)}` }],
4005
4833
  };
4006
4834
  }
4835
+ case "save_workstream_attachment": {
4836
+ return savePlanningAttachment('workstream', args);
4837
+ }
4838
+ case "save_initiative_attachment": {
4839
+ return savePlanningAttachment('initiative', args);
4840
+ }
4007
4841
  case "delete_task_attachment": {
4008
4842
  const { taskId: inputTaskId, attachmentId } = args;
4009
4843
  if (!attachmentId || typeof attachmentId !== 'string')
@@ -4063,6 +4897,12 @@ export function createTaskforceMcpServer(options = {}) {
4063
4897
  }],
4064
4898
  };
4065
4899
  }
4900
+ case "delete_workstream_attachment": {
4901
+ return deletePlanningAttachment('workstream', args);
4902
+ }
4903
+ case "delete_initiative_attachment": {
4904
+ return deletePlanningAttachment('initiative', args);
4905
+ }
4066
4906
  case "list_archive": {
4067
4907
  const { format = "markdown" } = args;
4068
4908
  const archive = core.listArchive(ACTIVE_WORKSPACE_ID);
@@ -4314,7 +5154,7 @@ export function createTaskforceMcpServer(options = {}) {
4314
5154
  if (!handler) {
4315
5155
  throw new Error(`Unknown tool: ${name}`);
4316
5156
  }
4317
- return await handler((args || {}));
5157
+ return await executeMcpToolWithReadRetry(name, (args || {}), handler);
4318
5158
  }
4319
5159
  catch (error) {
4320
5160
  const recoverable = buildRecoverableToolErrorResult(error, auditRequestId);
@@ -4341,7 +5181,14 @@ export function createTaskforceMcpServer(options = {}) {
4341
5181
  };
4342
5182
  }
4343
5183
  export async function startTaskforceMcpStdioServer(options = {}) {
4344
- const runtime = createTaskforceMcpServer(options);
5184
+ const envAuth = buildStdioAuthFromEnv();
5185
+ const runtime = createTaskforceMcpServer({
5186
+ ...options,
5187
+ auth: {
5188
+ ...envAuth,
5189
+ ...(options.auth || {}),
5190
+ },
5191
+ });
4345
5192
  const transport = new StdioServerTransport();
4346
5193
  await runtime.server.connect(transport);
4347
5194
  return { ...runtime, transport };