@taskforcehq/taskforce 0.3.303 → 0.3.304

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 (165) hide show
  1. package/dist/Taskforce.module.css +102 -5
  2. package/dist/TaskforceCore.js +514 -212
  3. package/dist/TaskforceCore.test.js +1172 -133
  4. package/dist/annotatedAttachments/service.d.ts +1 -1
  5. package/dist/annotatedAttachments/types.d.ts +1 -1
  6. package/dist/components/features/AgentsModule.js +1 -1
  7. package/dist/components/features/AgentsModule.test.js +1 -1
  8. package/dist/components/features/TaskSettings.js +58 -38
  9. package/dist/components/features/TaskSettings.test.js +139 -40
  10. package/dist/components/task/TaskActionHeader.d.ts +2 -1
  11. package/dist/components/task/TaskActionHeader.js +3 -3
  12. package/dist/components/task/TaskActionHeader.test.js +8 -0
  13. package/dist/components/task/TaskCard.js +16 -1
  14. package/dist/components/task/TaskCard.test.js +31 -0
  15. package/dist/components/task/TaskForm.d.ts +2 -1
  16. package/dist/components/task/TaskForm.js +22 -4
  17. package/dist/components/task/TaskForm.test.js +46 -0
  18. package/dist/components/task/TaskKanban.d.ts +1 -1
  19. package/dist/components/task/TaskKanban.js +31 -160
  20. package/dist/components/task/TaskStatusActions.d.ts +6 -1
  21. package/dist/components/task/TaskStatusActions.js +8 -3
  22. package/dist/components/task/TaskStatusActions.test.js +9 -0
  23. package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
  24. package/dist/components/task/taskKanbanDropRules.js +187 -0
  25. package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
  26. package/dist/components/task/taskKanbanDropRules.test.js +114 -0
  27. package/dist/components/ui/Modal.d.ts +1 -1
  28. package/dist/components/ui/Modal.js +1 -0
  29. package/dist/components/views/AppShellHeader.js +5 -1
  30. package/dist/components/views/AppShellHeader.test.js +4 -0
  31. package/dist/components/views/PlanComparisonPage.d.ts +3 -2
  32. package/dist/components/views/PlanComparisonPage.js +4 -2
  33. package/dist/components/views/PlanComparisonPage.test.js +4 -0
  34. package/dist/components/views/PlansPage.d.ts +5 -1
  35. package/dist/components/views/PlansPage.js +39 -12
  36. package/dist/components/views/PlansPage.test.js +59 -2
  37. package/dist/components/views/StandaloneLayout.js +113 -38
  38. package/dist/components/views/WidgetView.js +11 -2
  39. package/dist/components/views/standalone/modals/SyncStatusModal.js +1 -1
  40. package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
  41. package/dist/core/PlanEntitlementService.d.ts +3 -1
  42. package/dist/core/PlanEntitlementService.js +61 -11
  43. package/dist/core/SignupMonetizationSettings.test.js +37 -2
  44. package/dist/core/TaskActivityService.d.ts +4 -1
  45. package/dist/core/TaskActivityService.js +45 -0
  46. package/dist/core/TaskAuditTimeline.test.js +52 -1
  47. package/dist/core/Taskforce.d.ts +15 -11
  48. package/dist/core/Taskforce.js +43 -2
  49. package/dist/core/WorkspacePlanMode.test.js +65 -0
  50. package/dist/documentReviews/service.d.ts +1 -1
  51. package/dist/documentReviews/types.d.ts +1 -1
  52. package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
  53. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
  54. package/dist/hooks/sync/auth.d.ts +37 -0
  55. package/dist/hooks/sync/auth.js +61 -0
  56. package/dist/hooks/sync/bootstrap.d.ts +34 -0
  57. package/dist/hooks/sync/bootstrap.js +52 -0
  58. package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
  59. package/dist/hooks/sync/orchestratorShared.js +209 -0
  60. package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
  61. package/dist/hooks/sync/orchestratorShared.test.js +49 -0
  62. package/dist/hooks/sync/realtime.d.ts +25 -0
  63. package/dist/hooks/sync/realtime.js +60 -0
  64. package/dist/hooks/sync/recovery.d.ts +69 -0
  65. package/dist/hooks/sync/recovery.js +118 -0
  66. package/dist/hooks/sync/transfers.d.ts +122 -0
  67. package/dist/hooks/sync/transfers.js +431 -0
  68. package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
  69. package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
  70. package/dist/hooks/ui/useResourceExport.d.ts +3 -1
  71. package/dist/hooks/ui/useResourceExport.js +5 -2
  72. package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
  73. package/dist/hooks/useRealtimeSync.js +2 -1
  74. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
  75. package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
  76. package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
  77. package/dist/hooks/useSyncOrchestrator.d.ts +5 -4
  78. package/dist/hooks/useSyncOrchestrator.js +314 -873
  79. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +568 -3
  80. package/dist/hooks/useTaskMutations.js +45 -6
  81. package/dist/hooks/useTaskMutations.test.js +147 -0
  82. package/dist/hooks/useTaskforce.d.ts +8 -2
  83. package/dist/hooks/useTaskforce.js +285 -812
  84. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  85. package/dist/hooks/useWorkspaceSyncController.js +22 -10
  86. package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
  87. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
  88. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
  89. package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
  90. package/dist/hooks/workspace/workspaceLocalState.js +100 -0
  91. package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
  92. package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
  93. package/dist/localization/locales/en-US.js +2 -2
  94. package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
  95. package/dist/mcp/canonicalAssetHelpers.js +259 -0
  96. package/dist/mcp/clientIntegrations.d.ts +12 -1
  97. package/dist/mcp/clientIntegrations.js +161 -6
  98. package/dist/mcp/clientIntegrations.test.js +76 -4
  99. package/dist/mcp/documentHelpers.d.ts +100 -0
  100. package/dist/mcp/documentHelpers.js +161 -0
  101. package/dist/mcp/runtime.js +174 -476
  102. package/dist/mcp/runtime.test.js +81 -8
  103. package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
  104. package/dist/mcp/taskAttachmentHelpers.js +60 -0
  105. package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
  106. package/dist/mcp/taskAttachmentTypes.js +1 -0
  107. package/dist/plugins/vite.js +1 -1
  108. package/dist/runtime/appGateDefinitions.d.ts +1 -1
  109. package/dist/runtime/appGateDefinitions.js +1 -1
  110. package/dist/server/index.d.ts +1 -0
  111. package/dist/server/index.js +46 -3
  112. package/dist/server/index.test.js +12 -1
  113. package/dist/server/routes/admin.js +13 -19
  114. package/dist/server/routes/auth.js +37 -2
  115. package/dist/server/routes/billing.js +18 -67
  116. package/dist/server/routes/billing.test.js +43 -0
  117. package/dist/server/routes/shared.d.ts +30 -0
  118. package/dist/server/routes/shared.js +26 -0
  119. package/dist/server/routes/sync.integration.test.js +20 -0
  120. package/dist/server/routes/sync.js +110 -237
  121. package/dist/server/routes/tasks.js +49 -7
  122. package/dist/server/routes.js +8 -19
  123. package/dist/server/routes.test.js +243 -5
  124. package/dist/shared/taskActor.d.ts +10 -0
  125. package/dist/shared/taskActor.js +1 -0
  126. package/dist/sync/collaborationSyncPayload.js +4 -0
  127. package/dist/sync/collaborationSyncPayload.test.js +8 -0
  128. package/dist/sync/collaborationSyncState.d.ts +3 -1
  129. package/dist/sync/collaborationSyncState.js +19 -0
  130. package/dist/sync/contentSyncState.js +12 -10
  131. package/dist/sync/syncApplyHandlers.d.ts +87 -1
  132. package/dist/sync/syncApplyHandlers.js +370 -8
  133. package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
  134. package/dist/sync/syncApplyHandlers.test.js +377 -0
  135. package/dist/sync/syncService.d.ts +8 -1
  136. package/dist/sync/syncService.js +36 -2
  137. package/dist/sync/syncService.test.js +48 -0
  138. package/dist/sync/workspacePullFeed.d.ts +1 -0
  139. package/dist/sync/workspacePullFeed.js +66 -59
  140. package/dist/sync/workspacePullFeed.test.js +72 -4
  141. package/dist/sync/workspaceSyncModel.d.ts +22 -1
  142. package/dist/sync/workspaceSyncModel.js +65 -0
  143. package/dist/sync/workspaceSyncModel.test.js +119 -0
  144. package/dist/types.d.ts +2 -10
  145. package/dist/ui/assets/AgentsModule-BLWVbuKP.js +1 -0
  146. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-C_TmXZwA.js → AnnotatedAttachmentWorkspace-BlS-cqnl.js} +1 -1
  147. package/dist/ui/assets/{DocumentWorkspace-C3GyzrWm.js → DocumentWorkspace-BH_6DF6x.js} +1 -1
  148. package/dist/ui/assets/{InitiativesModule-4-Rh2K2n.js → InitiativesModule-BjR6iBf9.js} +1 -1
  149. package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
  150. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +1 -0
  151. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +9 -0
  152. package/dist/ui/assets/{WorkflowsModule-CGk9s3NJ.js → WorkflowsModule-DnFqdUME.js} +1 -1
  153. package/dist/ui/assets/index-DXUdtH1B.js +6 -0
  154. package/dist/ui/assets/index-Ii0B0rTO.css +1 -0
  155. package/dist/ui/assets/{vendor-icons-pWocEipT.js → vendor-icons-I7ZwG1z_.js} +1 -1
  156. package/dist/ui/index.html +4 -4
  157. package/dist/utils/taskActivity.js +4 -0
  158. package/dist/utils/taskActivity.test.js +25 -1
  159. package/package.json +3 -1
  160. package/scripts/playwright-auth.sh +4 -0
  161. package/dist/ui/assets/AgentsModule-CpsTmrIW.js +0 -1
  162. package/dist/ui/assets/PlansPage-BP7AYOqr.js +0 -1
  163. package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +0 -8
  164. package/dist/ui/assets/index-CLIMgR6g.js +0 -6
  165. package/dist/ui/assets/index-DneETxcu.css +0 -1
@@ -3,10 +3,14 @@ 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 { createHash, randomUUID } from 'node:crypto';
6
+ import { randomUUID } from 'node:crypto';
7
+ import * as os from 'node:os';
7
8
  import { TaskforceCore, TaskforceRuleError } from '../core/Taskforce.js'; // Note .js extension for ESM
8
9
  import { COMPLEXITY_OPTIONS, resolveNumberOption, resolveStringOption, STATUS_OPTIONS } from './optionResolution.js';
9
10
  import { buildResolveAiProfileInputFromBootstrap } from './aiProfileBootstrap.js';
11
+ import { createCanonicalAssetHelpers } from './canonicalAssetHelpers.js';
12
+ import { createDocumentHelpers } from './documentHelpers.js';
13
+ import { createTaskAttachmentHelpers } from './taskAttachmentHelpers.js';
10
14
  import { validateStructuredDocForAttach } from './structuredDocValidation.js';
11
15
  import { validateStructuredCommentForAdd } from './structuredCommentValidation.js';
12
16
  import { resolveDatabaseConfigFromEnv } from '../storage/providerConfig.js';
@@ -282,300 +286,38 @@ export function createTaskforceMcpServer(options = {}) {
282
286
  ? new ObjectStorageClient(objectStorageConfig)
283
287
  : null;
284
288
  const { basePath } = core.getPaths();
285
- const normalizeAttachmentStorageKey = (pathValue, fsPathValue) => {
286
- const fsPathRaw = String(fsPathValue || '').trim().replace(/\\/g, '/');
287
- if (fsPathRaw) {
288
- return fsPathRaw.replace(/^\.taskforce\//, '').trim() || null;
289
- }
290
- const pathRaw = String(pathValue || '').trim();
291
- if (!pathRaw)
292
- return null;
293
- if (/^https?:\/\//i.test(pathRaw))
294
- return pathRaw;
295
- if (pathRaw.startsWith('/api/taskforce/context/')) {
296
- return decodeURIComponent(pathRaw.slice('/api/taskforce/context/'.length)).trim() || null;
297
- }
298
- return null;
299
- };
300
- const buildSyntheticAttachmentId = (taskId, attachment) => {
301
- const stableSource = [
302
- taskId,
303
- String(attachment.path || '').trim(),
304
- String(attachment.fsPath || '').trim(),
305
- String(attachment.caption || '').trim(),
306
- ].join('::');
307
- return `attachment-${createHash('sha256').update(stableSource).digest('hex').slice(0, 24)}`;
308
- };
309
- const getAttachmentAssetRecord = (attachment) => {
310
- if (!workspaceAssetStore)
311
- return null;
312
- const assetId = String(attachment.assetId || '').trim();
313
- if (assetId) {
314
- const byId = workspaceAssetStore.get(assetId, ACTIVE_WORKSPACE_ID);
315
- if (byId && !byId.deletedAt)
316
- return byId;
317
- }
318
- const storageKey = normalizeAttachmentStorageKey(attachment.path, attachment.fsPath);
319
- if (!storageKey || /^https?:\/\//i.test(storageKey))
320
- return null;
321
- const byStorageKey = workspaceAssetStore.getByStorageKey(storageKey, ACTIVE_WORKSPACE_ID);
322
- if (byStorageKey && !byStorageKey.deletedAt)
323
- return byStorageKey;
324
- return null;
325
- };
326
- const isCanonicalDocumentAssetRecord = (asset) => isValidCanonicalDocumentAsset(asset);
327
- const toTaskAttachmentRecord = (attachment) => {
328
- if (typeof attachment === 'string') {
329
- return { path: attachment };
330
- }
331
- return attachment;
332
- };
333
- const describeTaskAttachment = (taskId, attachmentInput) => {
334
- const attachment = toTaskAttachmentRecord(attachmentInput);
335
- const asset = getAttachmentAssetRecord(attachment);
336
- const storageKey = normalizeAttachmentStorageKey(attachment.path, attachment.fsPath);
337
- const assetId = String(attachment.assetId || asset?.assetId || '').trim() || undefined;
338
- const source = (asset?.storageProvider === 'external' || /^https?:\/\//i.test(String(attachment.path || '').trim()))
339
- ? 'external'
340
- : 'canonical';
341
- const mimeType = String(asset?.mimeType
342
- || (storageKey && !/^https?:\/\//i.test(storageKey) ? inferMimeTypeFromPath(storageKey) : '')
343
- || inferMimeTypeFromPath(String(attachment.originalFilename || attachment.displayName || attachment.caption || attachment.path || ''))).trim() || 'application/octet-stream';
344
- const kind = asset?.kind || inferAssetKind(storageKey || String(attachment.path || ''), mimeType);
345
- const isCanonicalDocument = Boolean(asset && isCanonicalDocumentAssetRecord(asset)) || (!asset && kind === 'document');
346
- const referenceNumber = typeof attachment.referenceNumber === 'number'
347
- ? attachment.referenceNumber
348
- : (typeof asset?.referenceNumber === 'number' ? asset.referenceNumber : null);
349
- const referenceLabel = String(attachment.referenceLabel
350
- || (kind === 'image' ? getImageReferenceLabel(asset) : '')
351
- || (isCanonicalDocument ? getDocumentReferenceLabel(asset) : '')).trim() || null;
352
- const readableAsText = source === 'canonical' && isTextReadableMimeType(mimeType);
353
- const canonicalDocumentHint = isCanonicalDocument && source === 'canonical' && asset
354
- ? buildCanonicalDocumentAttachmentHint(asset)
355
- : null;
356
- return {
357
- attachmentId: assetId || buildSyntheticAttachmentId(taskId, attachment),
358
- ...(assetId ? { assetId } : {}),
359
- ...(referenceNumber ? { referenceNumber } : {}),
360
- ...(referenceLabel ? { referenceLabel } : {}),
361
- ...(canonicalDocumentHint?.documentId ? { documentId: canonicalDocumentHint.documentId } : {}),
362
- path: String(attachment.path || '').trim(),
363
- ...(storageKey && !/^https?:\/\//i.test(storageKey) ? { fsPath: storageKey } : (attachment.fsPath ? { fsPath: String(attachment.fsPath).trim() } : {})),
364
- ...(attachment.caption ? { caption: String(attachment.caption).trim() } : {}),
365
- ...(attachment.displayName ? { displayName: String(attachment.displayName).trim() } : {}),
366
- ...(attachment.originalFilename ? { originalFilename: String(attachment.originalFilename).trim() } : {}),
367
- kind: isCanonicalDocument ? 'document' : kind,
368
- mimeType,
369
- sizeBytes: asset ? Number(asset.sizeBytes || 0) : null,
370
- ...(attachment.timestamp ? { timestamp: String(attachment.timestamp).trim() } : {}),
371
- source,
372
- readableAsText,
373
- ...(canonicalDocumentHint ? {
374
- preferredNextTool: canonicalDocumentHint.preferredNextTool,
375
- preferredWorkflow: canonicalDocumentHint.preferredWorkflow,
376
- suggestedArgs: canonicalDocumentHint.suggestedArgs,
377
- guidance: canonicalDocumentHint.guidance,
378
- } : {}),
379
- };
380
- };
381
- const listTaskAttachmentDescriptors = (task) => {
382
- if (!task || !Array.isArray(task.attachments))
383
- return [];
384
- return task.attachments.map((attachment) => describeTaskAttachment(task.id, attachment));
385
- };
386
- const buildAttachmentDownloadUrl = (attachment) => {
387
- if (attachment.source === 'external')
388
- return attachment.path;
389
- const safeName = encodeURIComponent(String(attachment.caption || attachment.displayName || attachment.originalFilename || path.basename(attachment.fsPath || attachment.path) || 'download').trim());
390
- const joiner = attachment.path.includes('?') ? '&' : '?';
391
- return `${attachment.path}${joiner}download=1&filename=${safeName}`;
392
- };
393
- const describeAttachmentStorageContext = (attachment, storageKey) => {
394
- const parts = [
395
- `attachmentId=${attachment.attachmentId}`,
396
- `assetId=${String(attachment.assetId || 'unknown')}`,
397
- `storageKey=${storageKey || 'unknown'}`
398
- ];
399
- return parts.join(', ');
400
- };
401
- const readCanonicalAttachmentBuffer = async (taskId, attachment) => {
402
- const assetId = String(attachment.assetId || '').trim();
403
- const asset = assetId && workspaceAssetStore
404
- ? workspaceAssetStore.get(assetId, ACTIVE_WORKSPACE_ID)
405
- : null;
406
- if (!asset || asset.deletedAt) {
407
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
408
- }
409
- if (asset.scanStatus === 'infected') {
410
- throw new Error('Attachment is quarantined');
411
- }
412
- if (asset.scanStatus === 'pending') {
413
- throw new Error('Attachment scan pending');
414
- }
415
- if (asset.storageProvider === 'r2') {
416
- const attachmentContext = describeAttachmentStorageContext(attachment, asset.storageKey);
417
- if (!objectStorageClient) {
418
- throw new Error(`Attachment read failed from R2 because object storage is not configured in this MCP runtime (${attachmentContext})`);
419
- }
420
- let object;
421
- try {
422
- object = await objectStorageClient.getObject(asset.storageKey);
423
- }
424
- catch (error) {
425
- const message = error instanceof Error ? String(error.message || '').trim() : String(error || '').trim();
426
- throw new Error(`Attachment read failed from R2 (${attachmentContext}): ${message || 'Unknown object storage error'}`);
427
- }
428
- if (!object) {
429
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
430
- }
431
- return object.body;
432
- }
433
- const resolvedPath = path.resolve(basePath, asset.storageKey);
434
- if (!resolvedPath.startsWith(path.resolve(basePath))) {
435
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
436
- }
437
- if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
438
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
439
- }
440
- return fs.readFileSync(resolvedPath);
441
- };
442
- const readCanonicalDocumentBuffer = async (asset) => {
443
- if (asset.deletedAt || asset.kind !== 'document') {
444
- throw new Error(`Document ${asset.assetId} not found in workspace ${ACTIVE_WORKSPACE_ID}.`);
445
- }
446
- if (asset.scanStatus === 'infected') {
447
- throw new Error('Document is quarantined');
448
- }
449
- if (asset.scanStatus === 'pending') {
450
- throw new Error('Document scan pending');
451
- }
452
- if (asset.storageProvider === 'r2') {
453
- if (!objectStorageClient) {
454
- throw new Error(`Document read failed from R2 because object storage is not configured in this MCP runtime (assetId=${asset.assetId}, storageKey=${asset.storageKey || 'unknown'})`);
455
- }
456
- let object;
457
- try {
458
- object = await objectStorageClient.getObject(asset.storageKey);
459
- }
460
- catch (error) {
461
- const message = error instanceof Error ? String(error.message || '').trim() : String(error || '').trim();
462
- throw new Error(`Document read failed from R2 (assetId=${asset.assetId}, storageKey=${asset.storageKey || 'unknown'}): ${message || 'Unknown object storage error'}`);
463
- }
464
- if (!object) {
465
- throw new Error(`Document ${asset.assetId} not found in workspace ${ACTIVE_WORKSPACE_ID}.`);
466
- }
467
- return object.body;
468
- }
469
- const resolvedPath = path.resolve(basePath, asset.storageKey);
470
- if (!resolvedPath.startsWith(path.resolve(basePath))) {
471
- throw new Error(`Document ${asset.assetId} not found in workspace ${ACTIVE_WORKSPACE_ID}.`);
472
- }
473
- if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
474
- throw new Error(`Document ${asset.assetId} not found in workspace ${ACTIVE_WORKSPACE_ID}.`);
475
- }
476
- return fs.readFileSync(resolvedPath);
477
- };
478
- const buildDocumentDownloadUrl = (asset) => {
479
- const safeName = sanitizeFilenameForPath(asset.originalFilename
480
- || asset.logicalName
481
- || path.basename(String(asset.storageKey || '').trim())
482
- || 'document.txt', 'document.txt');
483
- return `/api/taskforce/documents/${encodeURIComponent(String(asset.assetId || '').trim())}/content?download=1&filename=${encodeURIComponent(safeName)}`;
484
- };
485
- const resolveAttachmentLogicalFilename = (attachmentInput, assetOverride) => {
486
- const attachment = toTaskAttachmentRecord(attachmentInput);
487
- const asset = assetOverride ?? getAttachmentAssetRecord(attachment);
488
- const candidate = String(attachment.originalFilename
489
- || asset?.originalFilename
490
- || attachment.displayName
491
- || asset?.logicalName
492
- || '').trim();
493
- if (candidate) {
494
- return sanitizeFilenameForPath(candidate);
495
- }
496
- const storageKey = normalizeAttachmentStorageKey(attachment.path, attachment.fsPath);
497
- return sanitizeFilenameForPath(storageKey ? path.basename(storageKey) : '', 'attachment.txt');
498
- };
499
- const resolveCanonicalOverwriteTarget = (task, filenameRaw) => {
500
- const requestedFilename = sanitizeFilenameForPath(filenameRaw);
501
- const existingAttachments = Array.isArray(task.attachments) ? task.attachments : [];
502
- for (let index = 0; index < existingAttachments.length; index++) {
503
- const attachment = toTaskAttachmentRecord(existingAttachments[index]);
504
- const asset = getAttachmentAssetRecord(attachment);
505
- if (!asset || asset.deletedAt || asset.storageProvider === 'external')
506
- continue;
507
- const descriptor = describeTaskAttachment(task.id, attachment);
508
- if (descriptor.source !== 'canonical')
509
- continue;
510
- const logicalFilename = resolveAttachmentLogicalFilename(attachment, asset);
511
- if (logicalFilename !== requestedFilename)
512
- continue;
513
- const relativePath = toPosixPath(path.relative(PROJECT_ROOT, path.resolve(basePath, asset.storageKey)));
514
- return {
515
- index,
516
- attachment,
517
- asset,
518
- descriptor,
519
- relativePath,
520
- absolutePath: path.resolve(basePath, asset.storageKey),
521
- logicalFilename,
522
- };
523
- }
524
- return null;
525
- };
526
- const ensureCanonicalTaskAttachment = (relativePathRaw, taskIdRaw, captionRaw, timestampRaw, role, options) => {
527
- if (!workspaceAssetStore)
528
- return null;
529
- const relativePath = String(relativePathRaw || '').trim();
530
- const taskId = String(taskIdRaw || '').trim();
531
- if (!relativePath || !taskId)
532
- return null;
533
- const absolutePath = path.resolve(PROJECT_ROOT, relativePath);
534
- if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile())
535
- return null;
536
- const storageKey = relativePath.replace(/^\.taskforce\//, '').trim() || relativePath;
537
- const buffer = fs.readFileSync(absolutePath);
538
- const stats = fs.statSync(absolutePath);
539
- const mimeType = inferMimeTypeFromPath(relativePath);
540
- const kind = inferAssetKind(relativePath, mimeType);
541
- const timestamp = String(timestampRaw || new Date().toISOString()).trim() || new Date().toISOString();
542
- const existing = workspaceAssetStore.getByStorageKey(storageKey, ACTIVE_WORKSPACE_ID);
543
- const originalFilename = sanitizeFilenameForPath(options?.originalFilename || existing?.originalFilename || path.basename(relativePath));
544
- const logicalName = sanitizeDisplayName(options?.logicalName || existing?.logicalName || originalFilename, originalFilename);
545
- const asset = workspaceAssetStore.upsertAsset({
546
- assetId: options?.assetId || existing?.assetId || `asset-${randomUUID().replace(/-/g, '')}`,
547
- workspaceId: ACTIVE_WORKSPACE_ID,
548
- kind,
549
- logicalName,
550
- originalFilename,
551
- mimeType,
552
- storageProvider: 'local',
553
- storageKey,
554
- contentSha256: createHash('sha256').update(buffer).digest('hex'),
555
- sizeBytes: stats.size,
556
- documentId: kind === 'document' ? (options?.documentId || existing?.documentId || `doc-${randomUUID().replace(/-/g, '')}`) : null,
557
- version: kind === 'document' ? Math.max(1, Number(existing?.version || 0) + 1) : null,
558
- isLatest: true,
559
- scanStatus: options?.scanStatus || existing?.scanStatus || 'clean',
560
- scanEngine: options?.scanEngine !== undefined ? options.scanEngine : (existing?.scanEngine || null),
561
- scanDetails: options?.scanDetails !== undefined ? options.scanDetails : (existing?.scanDetails || null),
562
- createdByUserId: options?.createdByUserId !== undefined ? options.createdByUserId : (existing?.createdByUserId || null),
563
- createdAt: options?.createdAt || existing?.createdAt || timestamp,
564
- updatedAt: timestamp,
565
- deletedAt: null,
566
- purgeAfter: null
567
- });
568
- workspaceAssetStore.upsertLink({
569
- workspaceId: ACTIVE_WORKSPACE_ID,
570
- assetId: asset.assetId,
571
- taskId,
572
- role,
573
- displayName: kind === 'document' ? null : (captionRaw || null),
574
- updatedAt: timestamp,
575
- deletedAt: null
576
- });
577
- return asset;
578
- };
289
+ const documentHelpers = createDocumentHelpers({
290
+ activeWorkspaceId: ACTIVE_WORKSPACE_ID,
291
+ workspaceAssetStore,
292
+ getDocumentReferenceLabel,
293
+ parseDocumentReference,
294
+ isTextReadableMimeType,
295
+ });
296
+ const canonicalAssetHelpers = createCanonicalAssetHelpers({
297
+ activeWorkspaceId: ACTIVE_WORKSPACE_ID,
298
+ projectRoot: PROJECT_ROOT,
299
+ basePath,
300
+ workspaceAssetStore,
301
+ objectStorageClient,
302
+ sanitizeFilenameForPath,
303
+ sanitizeDisplayName,
304
+ toPosixPath,
305
+ inferMimeTypeFromPath,
306
+ inferAssetKind,
307
+ buildCanonicalWorkspaceAssetRelativePath,
308
+ });
309
+ const taskAttachmentHelpers = createTaskAttachmentHelpers({
310
+ activeWorkspaceId: ACTIVE_WORKSPACE_ID,
311
+ workspaceAssetStore,
312
+ getImageReferenceLabel,
313
+ getDocumentReferenceLabel,
314
+ inferMimeTypeFromPath,
315
+ inferAssetKind,
316
+ isTextReadableMimeType,
317
+ isValidCanonicalDocumentAsset,
318
+ canonicalAssetHelpers,
319
+ documentHelpers,
320
+ });
579
321
  const server = new Server({
580
322
  name: options.serverName || `taskforce-${PROJECT_NAME}`,
581
323
  version: MCP_SERVER_VERSION,
@@ -669,75 +411,9 @@ export function createTaskforceMcpServer(options = {}) {
669
411
  }
670
412
  return { id: task.id, task };
671
413
  }
672
- function buildDocumentDescriptor(asset) {
673
- return {
674
- assetId: asset.assetId,
675
- documentId: asset.documentId ?? null,
676
- referenceNumber: typeof asset.referenceNumber === 'number' ? asset.referenceNumber : null,
677
- referenceLabel: getDocumentReferenceLabel(asset),
678
- logicalName: asset.logicalName ?? null,
679
- originalFilename: asset.originalFilename ?? null,
680
- version: typeof asset.version === 'number' ? asset.version : null,
681
- storageKey: asset.storageKey ?? null,
682
- mimeType: asset.mimeType ?? null,
683
- sizeBytes: typeof asset.sizeBytes === 'number' ? asset.sizeBytes : null,
684
- updatedAt: asset.updatedAt ?? null,
685
- apiUrl: `/api/taskforce/documents/${encodeURIComponent(String(asset.assetId || '').trim())}/content`,
686
- };
687
- }
688
- function buildDocumentReadHint(asset) {
689
- const preferredIdentifier = getDocumentReferenceLabel(asset)
690
- || (typeof asset.documentId === 'string' && asset.documentId.trim().length > 0 ? asset.documentId.trim() : null)
691
- || String(asset.assetId || '').trim();
692
- const readable = isTextReadableMimeType(asset.mimeType || '');
693
- if (readable) {
694
- return {
695
- available: true,
696
- nextTool: 'read_document_content',
697
- suggestedArgs: { documentId: preferredIdentifier },
698
- guidance: 'To read the document body, call read_document_content with this documentId.',
699
- reviewNext: {
700
- helperTool: 'review_document',
701
- listTool: 'get_document_review_sessions',
702
- createTool: 'create_document_review_session',
703
- commentTool: 'add_document_review_comment',
704
- guidance: 'After reading the document body, prefer review_document for a lower-friction review flow, or use get_document_review_sessions/create_document_review_session and add_document_review_comment directly.'
705
- }
706
- };
707
- }
708
- return {
709
- available: false,
710
- nextTool: 'read_document_content',
711
- suggestedArgs: { documentId: preferredIdentifier },
712
- guidance: 'This document is not inline-readable as text. Call read_document_content to get the structured download/reference response.',
713
- reviewNext: {
714
- helperTool: 'review_document',
715
- listTool: 'get_document_review_sessions',
716
- createTool: 'create_document_review_session',
717
- commentTool: 'add_document_review_comment',
718
- guidance: 'After inspecting the document access response, prefer review_document for a lower-friction review flow, or use get_document_review_sessions/create_document_review_session and add_document_review_comment directly.'
719
- },
720
- reason: 'binary_file',
721
- };
722
- }
723
- function buildCanonicalDocumentAttachmentHint(asset) {
724
- const preferredIdentifier = getDocumentReferenceLabel(asset)
725
- || (typeof asset.documentId === 'string' && asset.documentId.trim().length > 0 ? asset.documentId.trim() : null)
726
- || String(asset.assetId || '').trim();
727
- if (!preferredIdentifier) {
728
- return null;
729
- }
730
- const readable = isTextReadableMimeType(asset.mimeType || '');
731
- return {
732
- documentId: typeof asset.documentId === 'string' && asset.documentId.trim().length > 0 ? asset.documentId.trim() : null,
733
- preferredNextTool: 'get_document',
734
- preferredWorkflow: 'canonical_document',
735
- suggestedArgs: { documentId: preferredIdentifier },
736
- guidance: readable
737
- ? 'This attachment is a canonical document. Prefer get_document, then read_document_content, for ongoing document work and review.'
738
- : 'This attachment is a canonical document. Prefer get_document for metadata and review context, then read_document_content for the structured content/download response.',
739
- };
740
- }
414
+ const buildDocumentDescriptor = documentHelpers.buildDocumentDescriptor;
415
+ const buildDocumentReadHint = documentHelpers.buildDocumentReadHint;
416
+ const buildCanonicalDocumentAttachmentHint = documentHelpers.buildCanonicalDocumentAttachmentHint;
741
417
  function searchImageAssets(queryRaw, options) {
742
418
  const query = typeof queryRaw === 'string' ? queryRaw.trim().toLowerCase() : '';
743
419
  if (!query) {
@@ -796,65 +472,7 @@ export function createTaskforceMcpServer(options = {}) {
796
472
  .sort((a, b) => b.score - a.score || String(b.asset.updatedAt || '').localeCompare(String(a.asset.updatedAt || '')))
797
473
  .slice(0, limit);
798
474
  }
799
- function searchDocumentAssets(queryRaw, options) {
800
- const query = typeof queryRaw === 'string' ? queryRaw.trim().toLowerCase() : '';
801
- if (!query) {
802
- throw new Error('query is required');
803
- }
804
- if (!workspaceAssetStore) {
805
- throw new Error('Workspace asset store unavailable.');
806
- }
807
- const requestedLimit = Number(options?.limit);
808
- const limit = Number.isFinite(requestedLimit)
809
- ? Math.max(1, Math.min(50, Math.trunc(requestedLimit)))
810
- : 10;
811
- const normalize = (value) => typeof value === 'string' ? value.trim().toLowerCase() : '';
812
- const tokenize = (value) => normalize(value).split(/[^a-z0-9]+/i).filter(Boolean);
813
- const allDocuments = workspaceAssetStore.listAllByWorkspace(ACTIVE_WORKSPACE_ID, { kind: 'document' });
814
- const scoredMatches = allDocuments.flatMap((asset) => {
815
- if (asset.deletedAt)
816
- return [];
817
- const fields = [
818
- { label: 'referenceLabel', value: getDocumentReferenceLabel(asset) || '' },
819
- { label: 'documentId', value: asset.documentId || '' },
820
- { label: 'logicalName', value: asset.logicalName || '' },
821
- { label: 'originalFilename', value: asset.originalFilename || '' },
822
- { label: 'assetId', value: asset.assetId || '' },
823
- ];
824
- let score = 0;
825
- const matchedOn = new Set();
826
- for (const field of fields) {
827
- const normalized = normalize(field.value);
828
- if (!normalized)
829
- continue;
830
- if (normalized === query) {
831
- score += field.label === 'referenceLabel' || field.label === 'documentId' ? 120 : 90;
832
- matchedOn.add(field.label);
833
- continue;
834
- }
835
- if (normalized.startsWith(query)) {
836
- score += field.label === 'logicalName' || field.label === 'originalFilename' ? 60 : 50;
837
- matchedOn.add(field.label);
838
- continue;
839
- }
840
- if (normalized.includes(query)) {
841
- score += 35;
842
- matchedOn.add(field.label);
843
- continue;
844
- }
845
- if (tokenize(field.value).some((token) => token.startsWith(query))) {
846
- score += 20;
847
- matchedOn.add(field.label);
848
- }
849
- }
850
- if (score <= 0)
851
- return [];
852
- return [{ asset, matchedOn: Array.from(matchedOn), score }];
853
- });
854
- return scoredMatches
855
- .sort((a, b) => b.score - a.score || String(b.asset.updatedAt || '').localeCompare(String(a.asset.updatedAt || '')))
856
- .slice(0, limit);
857
- }
475
+ const searchDocumentAssets = documentHelpers.searchDocumentAssets;
858
476
  function buildImageDescriptor(asset) {
859
477
  return {
860
478
  assetId: asset.assetId,
@@ -869,32 +487,7 @@ export function createTaskforceMcpServer(options = {}) {
869
487
  apiUrl: `/api/taskforce/context/${encodeURIComponent(String(asset.storageKey || '').trim())}`,
870
488
  };
871
489
  }
872
- function resolveDocumentAssetByIdentifierOrThrow(documentIdentifier, options) {
873
- const normalizedValue = typeof documentIdentifier === 'string' ? documentIdentifier.trim() : '';
874
- if (!normalizedValue) {
875
- throw new Error('documentId is required');
876
- }
877
- if (!workspaceAssetStore) {
878
- throw new Error('Workspace asset store unavailable.');
879
- }
880
- const matchesDocumentAsset = (asset) => Boolean(asset && asset.kind === 'document' && asset.deletedAt == null);
881
- const directAsset = workspaceAssetStore.get(normalizedValue, ACTIVE_WORKSPACE_ID);
882
- if (matchesDocumentAsset(directAsset)) {
883
- return directAsset;
884
- }
885
- const documentAssets = workspaceAssetStore.listAllByWorkspace(ACTIVE_WORKSPACE_ID, { kind: 'document' });
886
- const referenceNumber = parseDocumentReference(normalizedValue);
887
- const matchedAsset = documentAssets.find((asset) => asset.documentId === normalizedValue)
888
- || (referenceNumber
889
- ? documentAssets.find((asset) => asset.referenceNumber === referenceNumber && asset.isLatest)
890
- || documentAssets.find((asset) => asset.referenceNumber === referenceNumber)
891
- : null);
892
- if (matchesDocumentAsset(matchedAsset)) {
893
- return matchedAsset;
894
- }
895
- throw new Error(options?.notFoundMessage
896
- || `Document ${normalizedValue} not found in workspace ${ACTIVE_WORKSPACE_ID}. If you only know a keyword, filename, or rough title, call search_documents first.`);
897
- }
490
+ const resolveDocumentAssetByIdentifierOrThrow = documentHelpers.resolveDocumentAssetByIdentifierOrThrow;
898
491
  function resolveImageAssetByIdentifierOrThrow(imageIdentifier, options) {
899
492
  const normalizedValue = typeof imageIdentifier === 'string' ? imageIdentifier.trim() : '';
900
493
  if (!normalizedValue) {
@@ -1419,13 +1012,91 @@ export function createTaskforceMcpServer(options = {}) {
1419
1012
  },
1420
1013
  });
1421
1014
  }
1015
+ function logMcpStructuredEvent(level, event, details) {
1016
+ const payload = {
1017
+ event,
1018
+ runtimeMode,
1019
+ workspaceId: ACTIVE_WORKSPACE_ID,
1020
+ requestId: auditRequestId,
1021
+ ...details,
1022
+ };
1023
+ const logger = level === 'error'
1024
+ ? console.error
1025
+ : level === 'warn'
1026
+ ? console.warn
1027
+ : console.info;
1028
+ logger(`[Taskforce MCP] ${JSON.stringify(payload)}`);
1029
+ }
1030
+ function logMcpReadObservation(toolName, startedAtMs, details = {}) {
1031
+ const durationMs = Date.now() - startedAtMs;
1032
+ const shouldLog = runtimeMode === 'cloud'
1033
+ ? toolName === 'get_current_profile' || toolName === 'list_tasks' || durationMs >= 150
1034
+ : durationMs >= 250;
1035
+ if (!shouldLog)
1036
+ return;
1037
+ logMcpStructuredEvent('info', 'tool_read_complete', {
1038
+ toolName,
1039
+ durationMs,
1040
+ ...details,
1041
+ });
1042
+ }
1422
1043
  /**
1423
1044
  * Helpers
1424
1045
  */
1425
- const taskforceManagedAttachmentSourceRoots = [
1426
- path.resolve(basePath, 'tmp'),
1427
- path.resolve(basePath, 'artifacts'),
1428
- ];
1046
+ function listExistingAttachmentSourceRoots(candidateRoots) {
1047
+ const seen = new Set();
1048
+ const results = [];
1049
+ for (const candidate of candidateRoots) {
1050
+ const trimmed = String(candidate || '').trim();
1051
+ if (!trimmed)
1052
+ continue;
1053
+ const resolved = path.resolve(trimmed);
1054
+ if (!fs.existsSync(resolved))
1055
+ continue;
1056
+ let realPath;
1057
+ try {
1058
+ realPath = fs.realpathSync(resolved);
1059
+ }
1060
+ catch {
1061
+ continue;
1062
+ }
1063
+ if (!fs.statSync(realPath).isDirectory())
1064
+ continue;
1065
+ if (seen.has(realPath))
1066
+ continue;
1067
+ seen.add(realPath);
1068
+ results.push(realPath);
1069
+ }
1070
+ return results;
1071
+ }
1072
+ function listConfiguredAttachmentSourceRoots() {
1073
+ const configured = String(process.env.TASKFORCE_ALLOWED_ATTACHMENT_SOURCE_ROOTS || '').trim();
1074
+ if (!configured)
1075
+ return [];
1076
+ return listExistingAttachmentSourceRoots(configured
1077
+ .split(path.delimiter)
1078
+ .map((entry) => entry.trim())
1079
+ .filter(Boolean));
1080
+ }
1081
+ function listKnownAgentWorkspaceRoots() {
1082
+ const openClawRoot = path.join(os.homedir(), '.openclaw');
1083
+ if (!fs.existsSync(openClawRoot))
1084
+ return [];
1085
+ const entries = fs.readdirSync(openClawRoot, { withFileTypes: true });
1086
+ const workspaceRoots = entries
1087
+ .filter((entry) => entry.isDirectory() && /^workspace-/i.test(entry.name))
1088
+ .map((entry) => path.join(openClawRoot, entry.name));
1089
+ return listExistingAttachmentSourceRoots(workspaceRoots);
1090
+ }
1091
+ function getApprovedAttachmentSourceRoots(projectRootReal) {
1092
+ return listExistingAttachmentSourceRoots([
1093
+ projectRootReal,
1094
+ path.resolve(basePath, 'tmp'),
1095
+ path.resolve(basePath, 'artifacts'),
1096
+ ...listConfiguredAttachmentSourceRoots(),
1097
+ ...listKnownAgentWorkspaceRoots(),
1098
+ ]);
1099
+ }
1429
1100
  function renderTaskTable(tasks) {
1430
1101
  if (tasks.length === 0)
1431
1102
  return "No tasks found.";
@@ -1458,9 +1129,7 @@ export function createTaskforceMcpServer(options = {}) {
1458
1129
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
1459
1130
  }
1460
1131
  function isApprovedAttachmentSourcePath(filePath, projectRootReal) {
1461
- if (isWithinRoot(filePath, projectRootReal))
1462
- return true;
1463
- return taskforceManagedAttachmentSourceRoots.some((root) => isWithinRoot(filePath, root));
1132
+ return getApprovedAttachmentSourceRoots(projectRootReal).some((root) => isWithinRoot(filePath, root));
1464
1133
  }
1465
1134
  function toPosixPath(filePath) {
1466
1135
  return filePath.split(path.sep).join('/');
@@ -1706,11 +1375,20 @@ export function createTaskforceMcpServer(options = {}) {
1706
1375
  async function flushScheduledResourceInvalidations() {
1707
1376
  if (pendingResourceInvalidations.size === 0)
1708
1377
  return;
1378
+ const startedAtMs = Date.now();
1709
1379
  const uris = Array.from(pendingResourceInvalidations);
1710
1380
  pendingResourceInvalidations.clear();
1711
1381
  for (const uri of uris) {
1712
1382
  await notifyResourceUpdatedIfSubscribed(uri);
1713
1383
  }
1384
+ const durationMs = Date.now() - startedAtMs;
1385
+ if (runtimeMode === 'cloud' || durationMs >= 100 || uris.length >= 3) {
1386
+ logMcpStructuredEvent('info', 'resource_invalidation_flush', {
1387
+ uriCount: uris.length,
1388
+ durationMs,
1389
+ uris,
1390
+ });
1391
+ }
1714
1392
  }
1715
1393
  function scheduleResourceMutation(options) {
1716
1394
  const uris = collectResourceMutationUris(options);
@@ -3059,7 +2737,7 @@ export function createTaskforceMcpServer(options = {}) {
3059
2737
  {
3060
2738
  name: "save_task_attachment",
3061
2739
  description: describeTool(runtimeMode === 'cloud'
3062
- ? "Save generated content into a task attachment and link it to the task. Use this when an agent creates notes, reports, or artifacts that should live with the task instead of remaining only in chat. For cloud MCP, AI-authored markdown/text should usually be passed inline via content rather than sourcePath. When overwrite=true, this replaces an existing canonical attachment with the same logical filename in place. External URL attachments are never overwrite targets; if no matching canonical attachment exists, a new attachment is created."
2740
+ ? "Save generated content into a task attachment and link it to the task. Use this when an agent creates notes, reports, or artifacts that should live with the task instead of remaining only in chat. For cloud MCP, use inline content for AI-authored markdown/text, or use sourcePath when the file already exists inside the repo, a Taskforce-managed attachment source, or an approved agent workspace root. When overwrite=true, this replaces an existing canonical attachment with the same logical filename in place. External URL attachments are never overwrite targets; if no matching canonical attachment exists, a new attachment is created."
3063
2741
  : "Save generated content or copy a file into a task-specific attachment and link it to the task. Use this when an agent creates notes, reports, or artifacts that should live with the task instead of remaining only in chat. When overwrite=true, this replaces an existing canonical attachment with the same logical filename in place. External URL attachments are never overwrite targets; if no matching canonical attachment exists, a new attachment is created."),
3064
2742
  inputSchema: {
3065
2743
  type: "object",
@@ -3067,7 +2745,7 @@ export function createTaskforceMcpServer(options = {}) {
3067
2745
  id: { type: "string", description: "Task ID" },
3068
2746
  filename: { type: "string", description: "Target filename for the task attachment." },
3069
2747
  content: { type: "string", description: runtimeMode === 'cloud' ? "Text/markdown file content to save. For cloud MCP, use this for AI-authored markdown/text attachments." : "Text/markdown file content to save. Provide either content or sourcePath." },
3070
- sourcePath: { type: "string", description: runtimeMode === 'cloud' ? "Project-relative or absolute source file path to copy (local runtime only today). For cloud MCP, prefer inline content for AI-authored markdown/text." : "Project-relative or absolute source file path to copy. Provide either sourcePath or content." },
2748
+ sourcePath: { type: "string", description: runtimeMode === 'cloud' ? "Project-relative or absolute source file path to copy. In cloud MCP this must resolve inside the repo, a Taskforce-managed attachment source, or an approved agent workspace root. Use content instead for AI-authored markdown/text." : "Project-relative or absolute source file path to copy. Provide either sourcePath or content." },
3071
2749
  caption: { type: "string", description: "Optional label shown in task context" },
3072
2750
  overwrite: { type: "boolean", description: "Overwrite the destination file when it already exists (default: false)." }
3073
2751
  },
@@ -3318,6 +2996,7 @@ export function createTaskforceMcpServer(options = {}) {
3318
2996
  assertResolvedProfileForMutatingTool(name);
3319
2997
  switch (name) {
3320
2998
  case "list_tasks": {
2999
+ const startedAtMs = Date.now();
3321
3000
  const { format = "markdown", category, status, openOnly = false, assignee, priority, type, sort = "created", order = "desc", limit } = args;
3322
3001
  let data = core.listTasks(ACTIVE_WORKSPACE_ID);
3323
3002
  let tasks = data.tasks;
@@ -3360,7 +3039,21 @@ export function createTaskforceMcpServer(options = {}) {
3360
3039
  // Strip verbose detail fields — descriptions, comments, events, and derived
3361
3040
  // activity are omitted from list responses to keep context window usage
3362
3041
  // manageable. Use get_task to retrieve full details for a specific task.
3363
- const slimTasks = tasks.map(({ description, comments, taskEvents, activity, createdByActor, assigneeActor, ...rest }) => rest);
3042
+ const slimTasks = tasks.map(({ description, comments, taskEvents, activity, createdByActor, assigneeActor, attachments, checklistItems, ...rest }) => rest);
3043
+ logMcpReadObservation('list_tasks', startedAtMs, {
3044
+ format,
3045
+ totalTasks: data.tasks.length,
3046
+ returnedTasks: slimTasks.length,
3047
+ filtered: slimTasks.length !== data.tasks.length,
3048
+ openOnly,
3049
+ hasAssigneeFilter: Boolean(assignee),
3050
+ hasStatusFilter: Boolean(status),
3051
+ hasCategoryFilter: Boolean(category),
3052
+ hasTypeFilter: Boolean(type),
3053
+ hasPriorityFilter: priority !== undefined,
3054
+ sort,
3055
+ order,
3056
+ });
3364
3057
  if (format === "json") {
3365
3058
  return {
3366
3059
  content: [{ type: "text", text: JSON.stringify(slimTasks, null, 2) }],
@@ -3428,6 +3121,7 @@ export function createTaskforceMcpServer(options = {}) {
3428
3121
  };
3429
3122
  }
3430
3123
  case "get_current_profile": {
3124
+ const startedAtMs = Date.now();
3431
3125
  const { profileToken } = args;
3432
3126
  ensureResolvedProfileForSessionBootstrap();
3433
3127
  let profile = activeAiProfileId
@@ -3444,6 +3138,13 @@ export function createTaskforceMcpServer(options = {}) {
3444
3138
  }
3445
3139
  bindingSource = 'token_lookup';
3446
3140
  }
3141
+ logMcpReadObservation('get_current_profile', startedAtMs, {
3142
+ identityBound: Boolean(activeAiProfileId),
3143
+ bindingSource,
3144
+ writeReady: Boolean(profile),
3145
+ profileResolved: Boolean(profile),
3146
+ usedProfileTokenLookup: !activeAiProfileId && typeof profileToken === 'string' && profileToken.trim().length > 0,
3147
+ });
3447
3148
  return {
3448
3149
  content: [{
3449
3150
  type: "text",
@@ -3782,7 +3483,7 @@ export function createTaskforceMcpServer(options = {}) {
3782
3483
  text: JSON.stringify({
3783
3484
  taskId: task.id,
3784
3485
  referenceLabel: task.referenceLabel || getTaskReferenceLabel(task),
3785
- attachments: listTaskAttachmentDescriptors(task)
3486
+ attachments: taskAttachmentHelpers.listTaskAttachmentDescriptors(task)
3786
3487
  }, null, 2)
3787
3488
  }],
3788
3489
  };
@@ -3793,7 +3494,7 @@ export function createTaskforceMcpServer(options = {}) {
3793
3494
  if (!task) {
3794
3495
  throw new Error(`Task ${taskId} not found`);
3795
3496
  }
3796
- const attachments = listTaskAttachmentDescriptors(task);
3497
+ const attachments = taskAttachmentHelpers.listTaskAttachmentDescriptors(task);
3797
3498
  const attachment = attachments.find((entry) => entry.attachmentId === attachmentId);
3798
3499
  if (!attachment) {
3799
3500
  throw new Error(`Attachment ${attachmentId} not found on task ${taskId}`);
@@ -3818,7 +3519,7 @@ export function createTaskforceMcpServer(options = {}) {
3818
3519
  taskId: task.id,
3819
3520
  attachment,
3820
3521
  format: "download",
3821
- downloadUrl: buildAttachmentDownloadUrl(attachment),
3522
+ downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
3822
3523
  reason: attachment.source === 'external' ? 'external_reference' : 'requested_download'
3823
3524
  }, null, 2)
3824
3525
  }],
@@ -3832,14 +3533,14 @@ export function createTaskforceMcpServer(options = {}) {
3832
3533
  taskId: task.id,
3833
3534
  attachment,
3834
3535
  format: "download",
3835
- downloadUrl: buildAttachmentDownloadUrl(attachment),
3536
+ downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
3836
3537
  reason: attachment.source === 'external' ? 'external_reference' : 'binary_file'
3837
3538
  }, null, 2)
3838
3539
  }],
3839
3540
  };
3840
3541
  }
3841
3542
  const resolvedMaxChars = Math.max(1, Math.floor(Number(maxChars || 200000)));
3842
- const buffer = await readCanonicalAttachmentBuffer(task.id, attachment);
3543
+ const buffer = await canonicalAssetHelpers.readCanonicalAttachmentBuffer(task.id, attachment);
3843
3544
  const content = buffer.toString('utf8');
3844
3545
  if (content.length > resolvedMaxChars) {
3845
3546
  throw new Error('Attachment exceeds maxChars limit for inline read');
@@ -4026,7 +3727,7 @@ export function createTaskforceMcpServer(options = {}) {
4026
3727
  text: JSON.stringify({
4027
3728
  document,
4028
3729
  format: "download",
4029
- downloadUrl: buildDocumentDownloadUrl(asset),
3730
+ downloadUrl: canonicalAssetHelpers.buildDocumentDownloadUrl(asset),
4030
3731
  reason: "requested_download"
4031
3732
  }, null, 2)
4032
3733
  }],
@@ -4039,14 +3740,14 @@ export function createTaskforceMcpServer(options = {}) {
4039
3740
  text: JSON.stringify({
4040
3741
  document,
4041
3742
  format: "download",
4042
- downloadUrl: buildDocumentDownloadUrl(asset),
3743
+ downloadUrl: canonicalAssetHelpers.buildDocumentDownloadUrl(asset),
4043
3744
  reason: "binary_file"
4044
3745
  }, null, 2)
4045
3746
  }],
4046
3747
  };
4047
3748
  }
4048
3749
  const resolvedMaxChars = Math.max(1, Math.floor(Number(maxChars || 200000)));
4049
- const buffer = await readCanonicalDocumentBuffer(asset);
3750
+ const buffer = await canonicalAssetHelpers.readCanonicalDocumentBuffer(asset);
4050
3751
  const content = buffer.toString('utf8');
4051
3752
  if (content.length > resolvedMaxChars) {
4052
3753
  throw new Error('Document exceeds maxChars limit for inline read');
@@ -5281,9 +4982,6 @@ export function createTaskforceMcpServer(options = {}) {
5281
4982
  if (hasContent === hasSourcePath) {
5282
4983
  throw new Error("Provide exactly one of: content or sourcePath");
5283
4984
  }
5284
- if (runtimeMode === 'cloud' && hasSourcePath) {
5285
- throw new Error("Cloud MCP does not support sourcePath for save_task_attachment yet. For AI-authored markdown or text, resend the attachment using inline content instead.");
5286
- }
5287
4985
  const now = new Date().toISOString();
5288
4986
  const safeCaption = typeof caption === 'string' && caption.trim().length > 0
5289
4987
  ? caption.trim()
@@ -5291,11 +4989,11 @@ export function createTaskforceMcpServer(options = {}) {
5291
4989
  const safeFilename = sanitizeFilenameForPath(filename);
5292
4990
  const { projectRoot, basePath } = core.getPaths();
5293
4991
  const overwriteTarget = overwrite === true
5294
- ? resolveCanonicalOverwriteTarget(task, safeFilename)
4992
+ ? canonicalAssetHelpers.resolveCanonicalOverwriteTarget(task, safeFilename, taskAttachmentHelpers.describeTaskAttachment)
5295
4993
  : null;
5296
4994
  const canonicalAsset = overwriteTarget
5297
4995
  ? null
5298
- : buildCanonicalWorkspaceAssetRelativePath({
4996
+ : canonicalAssetHelpers.buildCanonicalWorkspaceAssetRelativePath({
5299
4997
  workspaceIdRaw: ACTIVE_WORKSPACE_ID,
5300
4998
  kind: inferAssetKind(safeFilename, inferMimeTypeFromPath(safeFilename)),
5301
4999
  filename: safeFilename
@@ -5318,7 +5016,7 @@ export function createTaskforceMcpServer(options = {}) {
5318
5016
  const sourceReal = fs.realpathSync(sourceResolved);
5319
5017
  const projectRootReal = fs.realpathSync(projectRoot);
5320
5018
  if (!isApprovedAttachmentSourcePath(sourceReal, projectRootReal)) {
5321
- throw new Error("sourcePath must be within project root or a Taskforce-managed attachment source");
5019
+ throw new Error("sourcePath must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root");
5322
5020
  }
5323
5021
  if (!fs.statSync(sourceReal).isFile()) {
5324
5022
  throw new Error(`Path is not a file: ${trimmedSource}`);
@@ -5340,7 +5038,7 @@ export function createTaskforceMcpServer(options = {}) {
5340
5038
  const persistedCaption = isMarkdownDocument
5341
5039
  ? canonicalName
5342
5040
  : (safeCaption || overwriteTarget?.attachment.caption || safeFilename);
5343
- const asset = ensureCanonicalTaskAttachment(relativePath, id, persistedCaption, now, 'attachment', {
5041
+ const asset = canonicalAssetHelpers.ensureCanonicalTaskAttachment(relativePath, id, persistedCaption, now, 'attachment', {
5344
5042
  originalFilename: safeFilename,
5345
5043
  logicalName: isMarkdownDocument ? canonicalName : safeFilename,
5346
5044
  ...(overwriteTarget
@@ -5356,7 +5054,7 @@ export function createTaskforceMcpServer(options = {}) {
5356
5054
  : {})
5357
5055
  });
5358
5056
  const attachment = {
5359
- path: overwriteTarget?.attachment.path || (asset?.kind === 'document' && isCanonicalDocumentAssetRecord(asset) && asset.assetId
5057
+ path: overwriteTarget?.attachment.path || (asset?.kind === 'document' && isValidCanonicalDocumentAsset(asset) && asset.assetId
5360
5058
  ? `/api/taskforce/documents/${encodeURIComponent(asset.assetId)}/content`
5361
5059
  : `/api/taskforce/context/${encodeURIComponent((canonicalAsset || { relativePath: overwriteTarget?.descriptor.fsPath || '' }).relativePath)}`),
5362
5060
  fsPath: overwriteTarget?.attachment.fsPath || canonicalAsset?.relativePath || overwriteTarget?.descriptor.fsPath,
@@ -5426,7 +5124,7 @@ export function createTaskforceMcpServer(options = {}) {
5426
5124
  throw new Error('attachmentId is required');
5427
5125
  const { id, task } = resolveTaskByIdentifierOrThrow(inputTaskId);
5428
5126
  const existing = Array.isArray(task.attachments) ? task.attachments : [];
5429
- const descriptors = listTaskAttachmentDescriptors(task);
5127
+ const descriptors = taskAttachmentHelpers.listTaskAttachmentDescriptors(task);
5430
5128
  const targetIndex = descriptors.findIndex((entry) => entry.attachmentId === attachmentId);
5431
5129
  if (targetIndex < 0) {
5432
5130
  throw new Error(`Attachment ${attachmentId} not found on task ${inputTaskId}`);