@taskforcehq/taskforce 0.3.306 → 0.3.308
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.
- package/README.md +0 -2
- package/dist/Taskforce.module.css +317 -27
- package/dist/TaskforceCore.js +1 -0
- package/dist/TaskforceCore.test.js +178 -0
- package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
- package/dist/components/activity/EntityActivityTimeline.js +81 -0
- package/dist/components/context/ContextAttachmentManager.js +15 -3
- package/dist/components/features/AgentsModule.d.ts +5 -1
- package/dist/components/features/AgentsModule.js +142 -39
- package/dist/components/features/AgentsModule.test.js +260 -11
- package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
- package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
- package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
- package/dist/components/features/DocumentIndex.d.ts +2 -2
- package/dist/components/features/DocumentIndex.js +3 -4
- package/dist/components/features/DocumentViewer.d.ts +3 -1
- package/dist/components/features/DocumentViewer.js +6 -5
- package/dist/components/features/DocumentWorkspace.d.ts +10 -2
- package/dist/components/features/DocumentWorkspace.js +15 -4
- package/dist/components/features/DocumentWorkspace.test.js +20 -3
- package/dist/components/features/TaskSettings.d.ts +2 -0
- package/dist/components/features/TaskSettings.js +83 -78
- package/dist/components/features/TaskSettings.test.js +171 -82
- package/dist/components/task/TaskActionHeader.js +11 -5
- package/dist/components/task/TaskActionHeader.test.js +10 -0
- package/dist/components/task/TaskCard.js +6 -2
- package/dist/components/task/TaskContextUpload.test.js +3 -3
- package/dist/components/task/TaskForm.js +14 -64
- package/dist/components/task/TaskKanban.js +0 -8
- package/dist/components/task/TaskList.js +5 -1
- package/dist/components/task/TaskList.test.js +4 -0
- package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
- package/dist/components/ui/HierarchyActionBadge.js +5 -0
- package/dist/components/views/AppShellHeader.d.ts +3 -1
- package/dist/components/views/AppShellHeader.js +27 -11
- package/dist/components/views/AppShellHeader.test.js +18 -1
- package/dist/components/views/PlanComparisonPage.d.ts +20 -15
- package/dist/components/views/PlanComparisonPage.js +221 -82
- package/dist/components/views/PlanComparisonPage.test.js +268 -14
- package/dist/components/views/PlansPage.js +16 -10
- package/dist/components/views/PlansPage.test.js +4 -2
- package/dist/components/views/StandaloneLayout.js +256 -69
- package/dist/components/views/WidgetView.js +10 -8
- package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
- package/dist/components/views/panels/PlanningDrawer.js +259 -51
- package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
- package/dist/config/envSchema.js +1 -0
- package/dist/core/AiProfileService.d.ts +7 -0
- package/dist/core/AiProfileService.js +460 -35
- package/dist/core/AiProfiles.test.js +648 -2
- package/dist/core/AttachmentLinkService.d.ts +3 -0
- package/dist/core/AttachmentLinkService.js +58 -13
- package/dist/core/GlobalSettingsService.js +30 -0
- package/dist/core/GlobalSettingsService.test.d.ts +1 -0
- package/dist/core/GlobalSettingsService.test.js +45 -0
- package/dist/core/McpTokenService.d.ts +12 -0
- package/dist/core/McpTokenService.js +44 -1
- package/dist/core/PlanEntitlementService.d.ts +20 -0
- package/dist/core/PlanEntitlementService.js +215 -19
- package/dist/core/PlanFeatureCatalog.test.js +55 -0
- package/dist/core/PlanningEntities.test.js +366 -4
- package/dist/core/TaskActivityService.d.ts +28 -1
- package/dist/core/TaskActivityService.js +227 -46
- package/dist/core/TaskActivitySync.test.d.ts +1 -0
- package/dist/core/TaskActivitySync.test.js +90 -0
- package/dist/core/TaskAuditTimeline.test.js +96 -0
- package/dist/core/Taskforce.d.ts +204 -26
- package/dist/core/Taskforce.ids.test.js +58 -0
- package/dist/core/Taskforce.js +781 -71
- package/dist/core/Taskforce.mcpAuth.test.js +109 -0
- package/dist/core/types.d.ts +10 -0
- package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
- package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
- package/dist/hooks/bootstrapTimeouts.js +8 -0
- package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
- package/dist/hooks/bootstrapTimeouts.test.js +12 -0
- package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
- package/dist/hooks/sync/orchestratorShared.js +61 -1
- package/dist/hooks/sync/recovery.js +8 -0
- package/dist/hooks/sync/recovery.test.js +5 -5
- package/dist/hooks/sync/transfers.d.ts +2 -1
- package/dist/hooks/sync/transfers.js +36 -9
- package/dist/hooks/sync/transfers.test.js +534 -2
- package/dist/hooks/useFilterSort.js +13 -13
- package/dist/hooks/useFilterSort.test.js +87 -1
- package/dist/hooks/useSyncOrchestrator.js +50 -5
- package/dist/hooks/useTaskforce.d.ts +85 -50
- package/dist/hooks/useTaskforce.js +44 -14
- package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
- package/dist/hooks/useWorkspaceSyncController.js +7 -1
- package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
- package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
- package/dist/mcp/canonicalAssetHelpers.js +18 -10
- package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
- package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
- package/dist/mcp/clientIntegrations.d.ts +1 -0
- package/dist/mcp/clientIntegrations.js +25 -0
- package/dist/mcp/clientIntegrations.test.js +14 -3
- package/dist/mcp/clientProfileDefaults.js +3 -3
- package/dist/mcp/collaborationRegistrar.js +7 -7
- package/dist/mcp/documentAssetRegistrar.js +113 -7
- package/dist/mcp/runtime.d.ts +1 -0
- package/dist/mcp/runtime.js +969 -122
- package/dist/mcp/runtime.test.js +1083 -11
- package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
- package/dist/mcp/taskPlanningRegistrar.js +48 -0
- package/dist/mcp/toolRegistry.d.ts +56 -0
- package/dist/mcp/toolRegistry.js +8 -0
- package/dist/mcp/toolRegistry.test.js +4 -0
- package/dist/migrations/taskSchemaMigrations.js +58 -15
- package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
- package/dist/server/aiProfileAvatarAssets.js +319 -0
- package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
- package/dist/server/aiProfileAvatarAssets.test.js +213 -0
- package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
- package/dist/server/index.js +4 -0
- package/dist/server/index.test.js +6 -1
- package/dist/server/routes/admin.js +328 -9
- package/dist/server/routes/annotatedAttachments.js +51 -0
- package/dist/server/routes/auth.d.ts +11 -0
- package/dist/server/routes/auth.js +189 -27
- package/dist/server/routes/authSupport.d.ts +5 -0
- package/dist/server/routes/authSupport.js +13 -1
- package/dist/server/routes/billing.d.ts +1 -0
- package/dist/server/routes/billing.js +116 -63
- package/dist/server/routes/billing.test.js +162 -11
- package/dist/server/routes/documents.js +10 -0
- package/dist/server/routes/shared.js +0 -10
- package/dist/server/routes/sync.integration.test.js +221 -0
- package/dist/server/routes/sync.js +13 -3
- package/dist/server/routes/syncSnapshotRoutes.js +2 -0
- package/dist/server/routes/tasks.js +12 -9
- package/dist/server/routes.js +87 -8
- package/dist/server/routes.test.js +972 -23
- package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
- package/dist/shared/aiProfileSurfaceType.js +10 -8
- package/dist/storage/objectStorage.d.ts +1 -0
- package/dist/storage/objectStorage.js +2 -1
- package/dist/storage/objectStorageClient.d.ts +1 -0
- package/dist/storage/objectStorageClient.js +41 -8
- package/dist/storage/objectStorageClient.test.js +26 -1
- package/dist/storage/objectStorageConfig.js +3 -1
- package/dist/storage/objectStorageConfig.test.js +13 -1
- package/dist/sync/collaborationSyncPayload.js +2 -0
- package/dist/sync/collaborationSyncPayload.test.js +1 -0
- package/dist/sync/collaborationSyncState.d.ts +3 -3
- package/dist/sync/collaborationSyncState.js +19 -12
- package/dist/sync/contentSyncPayload.d.ts +5 -1
- package/dist/sync/contentSyncPayload.js +9 -0
- package/dist/sync/contentSyncPayload.test.js +39 -0
- package/dist/sync/contentSyncState.d.ts +5 -1
- package/dist/sync/contentSyncState.js +231 -37
- package/dist/sync/planningSyncPayload.js +5 -0
- package/dist/sync/planningSyncPayload.test.js +24 -0
- package/dist/sync/syncApplyHandlers.d.ts +4 -4
- package/dist/sync/syncApplyHandlers.js +89 -31
- package/dist/sync/syncApplyHandlers.test.js +184 -4
- package/dist/sync/syncResourceAdapters.d.ts +2 -0
- package/dist/sync/syncResourceAdapters.js +2 -0
- package/dist/sync/syncService.d.ts +1 -0
- package/dist/sync/syncService.js +13 -1
- package/dist/sync/syncService.test.js +39 -0
- package/dist/sync/workspacePullFeed.d.ts +4 -0
- package/dist/sync/workspacePullFeed.js +129 -70
- package/dist/sync/workspacePullFeed.test.js +49 -0
- package/dist/sync/workspaceSyncModel.d.ts +28 -6
- package/dist/sync/workspaceSyncModel.js +159 -45
- package/dist/sync/workspaceSyncModel.test.js +269 -3
- package/dist/types.d.ts +33 -2
- package/dist/ui/agent-logos/ChatGPT.png +0 -0
- package/dist/ui/agent-logos/Gemini CLI.jpeg +0 -0
- package/dist/ui/agent-logos/antigravity.jpeg +0 -0
- package/dist/ui/agent-logos/claude-code.jpeg +0 -0
- package/dist/ui/agent-logos/codex.jpeg +0 -0
- package/dist/ui/agent-logos/cursor.png +0 -0
- package/dist/ui/agent-logos/openclaw.jpeg +0 -0
- package/dist/ui/agent-logos/windsurf.png +0 -0
- package/dist/ui/assets/AgentsModule-BS4Pi_nC.js +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-CCckzWYZ.js +3 -0
- package/dist/ui/assets/ContextAttachmentManager-CfG_ER7C.js +3 -0
- package/dist/ui/assets/DocumentWorkspace-BVaWPA25.js +252 -0
- package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
- package/dist/ui/assets/EntityActivityTimeline-DSj0ThaK.js +1 -0
- package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
- package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-AxMkWtxH.js} +1 -1
- package/dist/ui/assets/PlansPage-B_z-D6Nh.css +1 -0
- package/dist/ui/assets/PlansPage-Ndt7mYfo.js +1 -0
- package/dist/ui/assets/TaskContextUpload-Dw4rTF4i.js +1 -0
- package/dist/ui/assets/TaskSettings-f2ga42_V.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-E4UL3mov.js} +4 -4
- package/dist/ui/assets/documentReferences-BOUcJm6-.js +1 -0
- package/dist/ui/assets/index-BvAbqx5Q.css +1 -0
- package/dist/ui/assets/index-MAHKvFqp.js +4 -0
- package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/ui/og-image.png +0 -0
- package/dist/ui/product/agent manager.png +0 -0
- package/dist/ui/product/agent_manager_02.png +0 -0
- package/dist/utils/aiProfileEvents.d.ts +7 -0
- package/dist/utils/aiProfileEvents.js +6 -0
- package/dist/utils/assignees.d.ts +6 -0
- package/dist/utils/assignees.js +8 -1
- package/dist/utils/assignees.test.d.ts +1 -0
- package/dist/utils/assignees.test.js +25 -0
- package/dist/utils/avatarDisplay.d.ts +1 -0
- package/dist/utils/avatarDisplay.js +15 -0
- package/dist/utils/contextFiles.d.ts +3 -1
- package/dist/utils/contextFiles.js +2 -1
- package/dist/utils/taskActivity.d.ts +7 -1
- package/dist/utils/taskActivity.js +63 -1
- package/dist/utils/taskActivity.test.js +23 -1
- package/dist/utils/taskSearch.d.ts +22 -0
- package/dist/utils/taskSearch.js +87 -0
- package/dist/utils/taskSearch.test.d.ts +1 -0
- package/dist/utils/taskSearch.test.js +69 -0
- package/dist/utils/taskUtils.js +10 -3
- package/dist/utils/taskUtils.test.d.ts +1 -0
- package/dist/utils/taskUtils.test.js +19 -0
- package/package.json +1 -1
- package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
- package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
- package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
- package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
- package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
- package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
- package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
- package/dist/ui/assets/index-C6k75jr8.js +0 -6
package/dist/mcp/runtime.js
CHANGED
|
@@ -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.
|
|
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
|
-
/^
|
|
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
|
|
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 =
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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,
|
|
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 === '
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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 ${
|
|
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)
|
|
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
|
|
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:
|
|
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
|
-
|
|
3869
|
-
|
|
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
|
|
3881
|
-
|
|
3882
|
-
|
|
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,
|
|
4506
|
+
kind: inferAssetKind(safeFilename, safeMimeType),
|
|
3885
4507
|
filename: safeFilename
|
|
3886
4508
|
});
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
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
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
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
|
-
|
|
3903
|
-
|
|
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
|
-
|
|
3908
|
-
|
|
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
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
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
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
?
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
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
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 };
|