@taskforcehq/taskforce 0.3.323 → 0.3.326

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/Taskforce.module.css +56 -0
  2. package/dist/components/features/TaskSettings.js +26 -9
  3. package/dist/components/task/TaskKanban.d.ts +2 -2
  4. package/dist/components/views/AppShellHeader.js +47 -2
  5. package/dist/components/views/StandaloneLayout.js +22 -22
  6. package/dist/components/views/panels/PlanningDrawer.d.ts +2 -2
  7. package/dist/components/views/panels/PlanningDrawer.js +26 -12
  8. package/dist/components/views/planningScope.d.ts +4 -0
  9. package/dist/components/views/planningScope.js +5 -0
  10. package/dist/core/AiProfileService.js +6 -1
  11. package/dist/core/Taskforce.d.ts +3 -0
  12. package/dist/core/Taskforce.js +21 -0
  13. package/dist/hooks/useTaskforce.js +74 -17
  14. package/dist/hooks/workspace/workspaceLocalState.d.ts +25 -0
  15. package/dist/hooks/workspace/workspaceLocalState.js +38 -0
  16. package/dist/mcp/adminWorkspaceRegistrar.js +0 -10
  17. package/dist/mcp/clientIntegrations.d.ts +1 -1
  18. package/dist/mcp/clientIntegrations.js +50 -0
  19. package/dist/mcp/clientProfileDefaults.js +4 -0
  20. package/dist/mcp/collaborationRegistrar.js +3 -3
  21. package/dist/mcp/documentAssetRegistrar.js +24 -72
  22. package/dist/mcp/documentHelpers.d.ts +11 -4
  23. package/dist/mcp/documentHelpers.js +21 -17
  24. package/dist/mcp/runtime.d.ts +4 -0
  25. package/dist/mcp/runtime.js +466 -534
  26. package/dist/mcp/taskPlanningRegistrar.js +45 -81
  27. package/dist/mcp/toolProfiles.d.ts +7 -0
  28. package/dist/mcp/toolProfiles.js +36 -0
  29. package/dist/mcp/toolRegistry.d.ts +87 -609
  30. package/dist/mcp/toolRegistry.js +93 -88
  31. package/dist/resources/templates/workflow-sources/workflowDocs.mjs +10 -10
  32. package/dist/resources/templates/workflows/collaborate.yaml +2 -3
  33. package/dist/resources/templates/workflows/do.yaml +2 -3
  34. package/dist/resources/templates/workflows/evaluate.yaml +2 -3
  35. package/dist/resources/templates/workflows/execute.yaml +12 -15
  36. package/dist/resources/templates/workflows/plan.yaml +3 -4
  37. package/dist/resources/templates/workflows/review.yaml +2 -3
  38. package/dist/server/routes/admin.d.ts +1 -0
  39. package/dist/server/routes/admin.js +29 -1
  40. package/dist/shared/taxonomyLibrary.js +1 -1
  41. package/dist/sync/cloudSyncApi.js +22 -3
  42. package/dist/types.js +1 -1
  43. package/dist/ui/.well-known/openai-apps-challenge +1 -1
  44. package/dist/ui/assets/{AgentsModule-7jpDeuVR.js → AgentsModule-C4LcqhcZ.js} +1 -1
  45. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BJLe8DFj.js → AnnotatedAttachmentWorkspace-B-Dq22d2.js} +1 -1
  46. package/dist/ui/assets/{ContextAttachmentManager-AmEny3eI.js → ContextAttachmentManager-DsXPTvi-.js} +1 -1
  47. package/dist/ui/assets/{DocumentWorkspace-Cqdq31Ha.js → DocumentWorkspace-CxA1wO3U.js} +1 -1
  48. package/dist/ui/assets/{EntityActivityTimeline-BlS7DhA4.js → EntityActivityTimeline-B8nSe8l3.js} +1 -1
  49. package/dist/ui/assets/{InitiativesModule-C1qfO1uZ.js → InitiativesModule-VY95v38E.js} +1 -1
  50. package/dist/ui/assets/{PlansPage-DFt5w-0Y.js → PlansPage-CQbKDv1a.js} +1 -1
  51. package/dist/ui/assets/{TaskContextUpload-zuro5gDT.js → TaskContextUpload-DvDlNpHC.js} +1 -1
  52. package/dist/ui/assets/TaskSettings-CUWbYYtP.js +12 -0
  53. package/dist/ui/assets/{WorkflowsModule-CEEGuxsf.js → WorkflowsModule-CgM3Jako.js} +1 -1
  54. package/dist/ui/assets/documentReferences-Do9YjrS3.js +1 -0
  55. package/dist/ui/assets/index--mh3pktj.js +5 -0
  56. package/dist/ui/assets/index-BawaaEXS.css +1 -0
  57. package/dist/ui/index.html +2 -2
  58. package/dist/utils/theme.js +1 -1
  59. package/package.json +1 -1
  60. package/src/resources/templates/workflow-sources/workflowDocs.mjs +10 -10
  61. package/src/resources/templates/workflows/collaborate.yaml +2 -3
  62. package/src/resources/templates/workflows/do.yaml +2 -3
  63. package/src/resources/templates/workflows/evaluate.yaml +2 -3
  64. package/src/resources/templates/workflows/execute.yaml +12 -15
  65. package/src/resources/templates/workflows/plan.yaml +3 -4
  66. package/src/resources/templates/workflows/review.yaml +2 -3
  67. package/dist/ui/assets/TaskSettings-CJPC2Bft.js +0 -9
  68. package/dist/ui/assets/documentReferences-Ca2P--i5.js +0 -1
  69. package/dist/ui/assets/index-CGDQY0nN.js +0 -5
  70. package/dist/ui/assets/index-DcK_8tuE.css +0 -1
@@ -28,7 +28,8 @@ import { resolveStorageKeyPathInsideRoot } from '../utils/pathSafety.js';
28
28
  import { parseAiProfileSurfaceType } from '../shared/aiProfileSurfaceType.js';
29
29
  import { parseAiProfileSeatScope } from '../shared/aiProfileSeatScope.js';
30
30
  import { formatInitiativeReference, formatWorkstreamReference, resolveInitiativeReference, parseWorkstreamReference, } from '../utils/planningReferences.js';
31
- import { MCP_TOOL_PERMISSIONS, MCP_WRITE_TOOL_NAMES } from './toolRegistry.js';
31
+ import { MCP_TOOL_PERMISSIONS, MCP_TOOL_REGISTRY, MCP_WRITE_TOOL_NAMES } from './toolRegistry.js';
32
+ import { getDefaultMcpToolProfile, getMcpToolRequiredProfiles, getMcpToolSuccessor, isMcpToolVisibleInProfile, normalizeMcpToolProfile, } from './toolProfiles.js';
32
33
  import { registerAdminWorkspaceTools } from './adminWorkspaceRegistrar.js';
33
34
  import { registerCollaborationTools } from './collaborationRegistrar.js';
34
35
  import { registerDocumentAssetTools } from './documentAssetRegistrar.js';
@@ -51,6 +52,21 @@ function buildStdioAuthFromEnv() {
51
52
  aiProfileSeatScope: parseAiProfileSeatScope(readEnvValue('TASKFORCE_AI_SEAT_SCOPE')),
52
53
  };
53
54
  }
55
+ class ToolProfileHiddenError extends Error {
56
+ code = 'TOOL_PROFILE_HIDDEN';
57
+ activeProfile;
58
+ requiredProfiles;
59
+ suggestedTool;
60
+ constructor(name, activeProfile) {
61
+ const requiredProfiles = getMcpToolRequiredProfiles(name);
62
+ const suggestedTool = getMcpToolSuccessor(name);
63
+ const suffix = suggestedTool ? ` Use ${suggestedTool.name} instead.` : ` Request one of these profiles to use it: ${requiredProfiles.join(', ')}.`;
64
+ super(`Tool ${name} is not available in the ${activeProfile} MCP tool profile.${suffix}`);
65
+ this.activeProfile = activeProfile;
66
+ this.requiredProfiles = requiredProfiles;
67
+ this.suggestedTool = suggestedTool;
68
+ }
69
+ }
54
70
  export function isMcpWriteToolName(name) {
55
71
  return MCP_WRITE_TOOL_NAMES.has(String(name || '').trim());
56
72
  }
@@ -154,6 +170,21 @@ function resolveRecoverableToolError(error) {
154
170
  const code = error instanceof TaskforceRuleError ? error.code : undefined;
155
171
  const statusCode = error instanceof TaskforceRuleError ? error.statusCode : undefined;
156
172
  const details = error instanceof TaskforceRuleError ? redactRecoverableErrorDetails(error.details) : undefined;
173
+ if (error instanceof ToolProfileHiddenError) {
174
+ return {
175
+ message,
176
+ code: error.code,
177
+ details: {
178
+ activeProfile: error.activeProfile,
179
+ requiredProfiles: error.requiredProfiles,
180
+ ...(error.suggestedTool ? { suggestedTool: error.suggestedTool } : {}),
181
+ },
182
+ ...(error.suggestedTool ? {
183
+ nextTool: error.suggestedTool.name,
184
+ suggestedArgs: error.suggestedTool.argumentHint || {},
185
+ } : {}),
186
+ };
187
+ }
157
188
  if (/^This tool requires an AI profile\./i.test(message)) {
158
189
  return {
159
190
  message,
@@ -272,6 +303,13 @@ function classifyTransientMcpError(error) {
272
303
  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.'
273
304
  };
274
305
  }
306
+ if (/(EOF while parsing|Unexpected end of JSON|SyntaxError.*JSON|Unexpected token .*JSON|invalid json response body|non-JSON response|text\/plain|502 Bad Gateway|522 Connection Timed Out|bad gateway|upstream connect error)/i.test(combined)) {
307
+ return {
308
+ kind: 'transport_failure',
309
+ code: 'MCP_TRANSPORT_FAILURE',
310
+ retryHint: 'The tool transport received an empty or non-JSON response, likely from a gateway interruption. Retry the same read once; if it repeats, inspect the cloud MCP route logs with this requestId.',
311
+ };
312
+ }
275
313
  if (/(timed out|timeout|ETIMEDOUT|query canceled|statement timeout|database timeout|postgres worker)/i.test(combined)) {
276
314
  return {
277
315
  kind: 'backend_timeout',
@@ -393,12 +431,16 @@ export function createTaskforceMcpServer(options = {}) {
393
431
  const PROJECT_NAME = path.basename(PROJECT_ROOT);
394
432
  const logStartup = options.logStartup !== false;
395
433
  const runtimeMode = options.runtimeMode === 'cloud' ? 'cloud' : 'local';
434
+ const defaultToolProfile = getDefaultMcpToolProfile(runtimeMode);
435
+ const envToolProfile = runtimeMode === 'local' ? readEnvValue('TASKFORCE_MCP_TOOL_PROFILE') : null;
436
+ const activeToolProfile = normalizeMcpToolProfile(options.toolProfile ?? envToolProfile, defaultToolProfile);
396
437
  const allowedScopes = Array.isArray(options.auth?.scopes)
397
438
  ? Array.from(new Set(options.auth.scopes))
398
439
  : null;
399
440
  const configuredAiProfileSeatScope = runtimeMode === 'cloud' ? 'cloud_metered' : 'local_unmetered';
400
441
  if (logStartup) {
401
442
  console.error(`[Taskforce MCP] Starting for project: ${PROJECT_NAME} at ${PROJECT_ROOT}`);
443
+ console.error(`[Taskforce MCP] Tool profile: ${activeToolProfile}`);
402
444
  }
403
445
  const tenantId = String(options.tenantId || options.core?.getTenantId() || process.env.TASKFORCE_TENANT_ID || 'default').trim() || 'default';
404
446
  const MCP_SERVER_VERSION = (() => {
@@ -505,7 +547,7 @@ export function createTaskforceMcpServer(options = {}) {
505
547
  },
506
548
  tools: {},
507
549
  },
508
- 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.`,
550
+ 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_image_annotations to list saved sessions or read a 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; pass includeContent=true when you need the document body. For a lower-friction review flow, prefer review_document; otherwise use get_document_reviews or create_document_review_session, then add_document_review_comment to record review feedback.`,
509
551
  });
510
552
  const TOOL_PERMISSIONS = MCP_TOOL_PERMISSIONS;
511
553
  const RESOURCE_PERMISSIONS = {
@@ -532,6 +574,13 @@ export function createTaskforceMcpServer(options = {}) {
532
574
  return;
533
575
  throw new Error(`Tool ${name} is not available for this MCP session.`);
534
576
  }
577
+ function assertToolProfile(name) {
578
+ if (!MCP_TOOL_REGISTRY[name])
579
+ return;
580
+ if (isMcpToolVisibleInProfile(name, activeToolProfile))
581
+ return;
582
+ throw new ToolProfileHiddenError(name, activeToolProfile);
583
+ }
535
584
  function resolveResourcePermissionKey(uri) {
536
585
  if (uri === RESOURCE_CONFIG_URI)
537
586
  return 'config';
@@ -590,6 +639,45 @@ export function createTaskforceMcpServer(options = {}) {
590
639
  const buildDocumentDescriptor = documentHelpers.buildDocumentDescriptor;
591
640
  const buildDocumentReadHint = documentHelpers.buildDocumentReadHint;
592
641
  const buildCanonicalDocumentAttachmentHint = documentHelpers.buildCanonicalDocumentAttachmentHint;
642
+ async function buildDocumentContentPayload(asset, options) {
643
+ const document = buildDocumentDescriptor(asset);
644
+ const formatRaw = typeof options?.format === 'string' ? options.format.trim().toLowerCase() : '';
645
+ const format = formatRaw === 'content' || formatRaw === 'full' ? 'text' : formatRaw;
646
+ if (format === "metadata") {
647
+ return {
648
+ document,
649
+ format: "metadata"
650
+ };
651
+ }
652
+ if (format === "download") {
653
+ return {
654
+ document,
655
+ format: "download",
656
+ downloadUrl: canonicalAssetHelpers.buildDocumentDownloadUrl(asset),
657
+ reason: "requested_download"
658
+ };
659
+ }
660
+ if (!isTextReadableMimeType(asset.mimeType || '')) {
661
+ return {
662
+ document,
663
+ format: "download",
664
+ downloadUrl: canonicalAssetHelpers.buildDocumentDownloadUrl(asset),
665
+ reason: "binary_file"
666
+ };
667
+ }
668
+ const resolvedMaxChars = Math.max(1, Math.floor(Number(options?.maxChars || 200000)));
669
+ const buffer = await canonicalAssetHelpers.readCanonicalDocumentBuffer(asset);
670
+ const content = buffer.toString('utf8');
671
+ if (content.length > resolvedMaxChars) {
672
+ throw new Error('Document exceeds maxChars limit for inline read');
673
+ }
674
+ return {
675
+ document,
676
+ format: "text",
677
+ truncated: false,
678
+ content
679
+ };
680
+ }
593
681
  function searchImageAssets(queryRaw, options) {
594
682
  const query = typeof queryRaw === 'string' ? queryRaw.trim().toLowerCase() : '';
595
683
  if (!query) {
@@ -773,12 +861,59 @@ export function createTaskforceMcpServer(options = {}) {
773
861
  lastActiveAt: profile.lastActiveAt ?? null,
774
862
  };
775
863
  }
864
+ /**
865
+ * Build the common attribution payload used by both resolve_profile and get_current_profile.
866
+ * When profile is null, bindingSource is always 'none' (the parameter is ignored in the null path).
867
+ */
868
+ function buildAiProfileAttribution(profile, bindingSource) {
869
+ if (!profile) {
870
+ return {
871
+ identityBound: false,
872
+ bindingSource: 'none',
873
+ writeReady: false,
874
+ profileToken: null,
875
+ profile: null,
876
+ taskFilter: null,
877
+ nextTool: 'resolve_profile',
878
+ suggestedArgs: { name: 'Your AI name' },
879
+ guidance: "No AI profile is currently bound. Call resolve_profile first, then use the returned profile id as the assignee filter for list_tasks."
880
+ };
881
+ }
882
+ return {
883
+ identityBound: true,
884
+ bindingSource,
885
+ writeReady: true,
886
+ profileToken: profile.profileToken ?? null,
887
+ profile: buildAiProfileDescriptor(profile),
888
+ taskFilter: { assignee: profile.id },
889
+ nextTool: 'list_tasks',
890
+ suggestedArgs: { assignee: profile.id, format: 'json' },
891
+ guidance: "Use suggestedArgs with list_tasks to get tasks assigned to this AI profile."
892
+ };
893
+ }
776
894
  function resolveRuntimeTaskAssigneeInput(value) {
895
+ if (isCurrentAiAssigneeInput(value)) {
896
+ const currentAiAssignee = String(activeAiProfileId || '').trim();
897
+ if (!currentAiAssignee) {
898
+ throw new Error('This tool requires an AI profile. Call resolve_profile first, then retry.');
899
+ }
900
+ return currentAiAssignee;
901
+ }
777
902
  const resolved = resolveTaskAssigneeInput(value);
778
903
  if (resolved === 'agent')
779
904
  return 'unassigned';
780
905
  return resolved;
781
906
  }
907
+ function resolveListTasksAssigneeInput(value) {
908
+ if (!isCurrentAiAssigneeInput(value))
909
+ return value;
910
+ ensureResolvedProfileForSessionBootstrap();
911
+ const currentAiAssignee = String(activeAiProfileId || '').trim();
912
+ if (!currentAiAssignee) {
913
+ throw new Error('This tool requires an AI profile. Call get_current_profile or resolve_profile first, then retry.');
914
+ }
915
+ return currentAiAssignee;
916
+ }
782
917
  function resolvePlanningOwnerInput(value) {
783
918
  const resolved = resolveRuntimeTaskAssigneeInput(value);
784
919
  if (resolved === undefined)
@@ -1294,7 +1429,7 @@ export function createTaskforceMcpServer(options = {}) {
1294
1429
  function logMcpReadObservation(toolName, startedAtMs, details = {}) {
1295
1430
  const durationMs = Date.now() - startedAtMs;
1296
1431
  const shouldLog = runtimeMode === 'cloud'
1297
- ? toolName === 'get_current_profile' || toolName === 'list_tasks' || durationMs >= 150
1432
+ ? toolName === 'get_config' || toolName === 'get_current_profile' || toolName === 'list_tasks' || durationMs >= 150
1298
1433
  : durationMs >= 250;
1299
1434
  if (!shouldLog)
1300
1435
  return;
@@ -1304,7 +1439,24 @@ export function createTaskforceMcpServer(options = {}) {
1304
1439
  ...details,
1305
1440
  });
1306
1441
  }
1307
- const burstyQaReadToolNames = new Set([
1442
+ // Tools eligible for bounded server-side retry on classified transient read failures.
1443
+ const retryableReadToolNames = new Set([
1444
+ // Safe reads called on every agent polling cycle
1445
+ 'get_config',
1446
+ 'get_current_profile',
1447
+ 'list_tasks',
1448
+ // Bursty QA reads that can encounter transient DB contention
1449
+ 'get_initiative',
1450
+ 'get_workstream',
1451
+ 'list_initiatives',
1452
+ 'list_workstreams',
1453
+ 'get_workstream_attachments',
1454
+ 'get_initiative_attachments',
1455
+ 'get_document',
1456
+ ]);
1457
+ // Subset of retryable reads that use wrapper-level success logging.
1458
+ // Excluded: get_config, get_current_profile, list_tasks log from their own handlers.
1459
+ const wrapperObservedReadToolNames = new Set([
1308
1460
  'get_initiative',
1309
1461
  'get_workstream',
1310
1462
  'list_initiatives',
@@ -1312,16 +1464,15 @@ export function createTaskforceMcpServer(options = {}) {
1312
1464
  'get_workstream_attachments',
1313
1465
  'get_initiative_attachments',
1314
1466
  'get_document',
1315
- 'read_document_content',
1316
1467
  ]);
1317
- const burstyQaReadRetryDelaysMs = [125, 350];
1468
+ const retryableReadRetryDelaysMs = [125, 350];
1318
1469
  async function executeMcpToolWithReadRetry(toolName, args, handler) {
1319
1470
  const startedAtMs = Date.now();
1320
1471
  let attempt = 0;
1321
1472
  for (;;) {
1322
1473
  try {
1323
1474
  const result = await handler(args);
1324
- if (burstyQaReadToolNames.has(toolName)) {
1475
+ if (wrapperObservedReadToolNames.has(toolName)) {
1325
1476
  logMcpReadObservation(toolName, startedAtMs, {
1326
1477
  attempts: attempt + 1,
1327
1478
  retried: attempt > 0,
@@ -1331,8 +1482,8 @@ export function createTaskforceMcpServer(options = {}) {
1331
1482
  }
1332
1483
  catch (error) {
1333
1484
  const transient = classifyTransientMcpError(error);
1334
- if (!burstyQaReadToolNames.has(toolName) || !transient || attempt >= burstyQaReadRetryDelaysMs.length) {
1335
- if (burstyQaReadToolNames.has(toolName) && transient) {
1485
+ if (!retryableReadToolNames.has(toolName) || !transient || attempt >= retryableReadRetryDelaysMs.length) {
1486
+ if (retryableReadToolNames.has(toolName) && transient) {
1336
1487
  logMcpStructuredEvent('warn', 'tool_read_retry_exhausted', {
1337
1488
  toolName,
1338
1489
  attempts: attempt + 1,
@@ -1343,7 +1494,7 @@ export function createTaskforceMcpServer(options = {}) {
1343
1494
  }
1344
1495
  throw error;
1345
1496
  }
1346
- const delayMs = burstyQaReadRetryDelaysMs[attempt];
1497
+ const delayMs = retryableReadRetryDelaysMs[attempt];
1347
1498
  attempt += 1;
1348
1499
  logMcpStructuredEvent('warn', 'tool_read_retry', {
1349
1500
  toolName,
@@ -1598,17 +1749,24 @@ export function createTaskforceMcpServer(options = {}) {
1598
1749
  return 'unassigned';
1599
1750
  return raw;
1600
1751
  }
1752
+ function isCurrentAiAssigneeInput(value) {
1753
+ if (value === undefined || value === null)
1754
+ return false;
1755
+ const normalized = String(value).trim().toLowerCase();
1756
+ return normalized === 'self';
1757
+ }
1601
1758
  function formatEligibleAssigneeHint(actors) {
1602
1759
  const eligible = actors.filter((actor) => String(actor.id || '').trim() && String(actor.id || '').trim().toLowerCase() !== 'agent');
1760
+ const selfHint = "Use 'self' to assign to the active AI profile, use 'unassigned' to clear ownership";
1603
1761
  if (eligible.length === 0) {
1604
- return "Use 'unassigned' to clear ownership, or pass a workspace member user ID or AI profile ID.";
1762
+ return `${selfHint}, or pass a workspace member user ID or AI profile ID.`;
1605
1763
  }
1606
1764
  const preview = eligible
1607
1765
  .slice(0, 8)
1608
1766
  .map((actor) => `${actor.id} (${actor.label})`)
1609
1767
  .join(', ');
1610
1768
  const remainder = eligible.length > 8 ? `, plus ${eligible.length - 8} more` : '';
1611
- return `Use 'unassigned' to clear ownership, or pass an eligible assignee ID from this workspace: ${preview}${remainder}.`;
1769
+ return `${selfHint}, or pass an eligible assignee ID from this workspace: ${preview}${remainder}.`;
1612
1770
  }
1613
1771
  function taskUriFor(id) {
1614
1772
  return `${RESOURCE_URI_PREFIX}/tasks/${encodeURIComponent(id)}`;
@@ -1742,6 +1900,13 @@ export function createTaskforceMcpServer(options = {}) {
1742
1900
  name: options.serverName || (runtimeMode === 'cloud' ? 'taskforce-cloud' : PROJECT_NAME),
1743
1901
  version: MCP_SERVER_VERSION,
1744
1902
  runtimeMode,
1903
+ toolProfile: activeToolProfile,
1904
+ },
1905
+ mcpToolProfile: {
1906
+ active: activeToolProfile,
1907
+ available: ['default', 'extended', 'full'],
1908
+ defaultForRuntime: defaultToolProfile,
1909
+ guidance: 'Cloud sessions default to the default profile. Local/dev sessions default to full. Use TASKFORCE_MCP_TOOL_PROFILE=default|extended|full for local stdio, or pass an explicit toolProfile runtime option where supported.',
1745
1910
  },
1746
1911
  scope: {
1747
1912
  workspaceId: ACTIVE_WORKSPACE_ID,
@@ -2193,13 +2358,10 @@ export function createTaskforceMcpServer(options = {}) {
2193
2358
  const assigneeHint = describeEligibleAssignees();
2194
2359
  const mutatingProfileTokenProperty = {
2195
2360
  type: "string",
2196
- description: "Optional AI profile token returned by resolve_profile. Pass this when your MCP client does not preserve session identity between calls. In stateless/cloud MCP clients without ambient AI binding, this is usually required on mutating calls after bootstrap."
2361
+ description: "Optional profileToken from resolve_profile for stateless MCP clients."
2197
2362
  };
2198
2363
  const prefix = `[Project: ${PROJECT_NAME}] `;
2199
- const agentNote = runtimeMode === 'local'
2200
- ? " NOTE: Use this tool instead of editing files directly to ensure data integrity."
2201
- : "";
2202
- const describeTool = (text) => prefix + text + agentNote;
2364
+ const describeTool = (text) => prefix + text;
2203
2365
  const titleForTool = (name) => String(name || '')
2204
2366
  .split('_')
2205
2367
  .filter(Boolean)
@@ -2261,7 +2423,8 @@ export function createTaskforceMcpServer(options = {}) {
2261
2423
  }
2262
2424
  }
2263
2425
  };
2264
- }).filter((tool) => hasPermission(TOOL_PERMISSIONS[tool.name]));
2426
+ }).filter((tool) => (isMcpToolVisibleInProfile(tool.name, activeToolProfile)
2427
+ && hasPermission(TOOL_PERMISSIONS[tool.name])));
2265
2428
  return {
2266
2429
  tools,
2267
2430
  };
@@ -2314,58 +2477,82 @@ export function createTaskforceMcpServer(options = {}) {
2314
2477
  ...(entity?.initiativeId ? { includeInitiatives: true, initiativeIds: [entity.initiativeId] } : {}),
2315
2478
  });
2316
2479
  };
2317
- const buildPlanningAttachmentListPayload = (targetType, target) => ({
2318
- [`${targetType}Id`]: target.id,
2319
- referenceLabel: target.referenceLabel,
2320
- attachments: listPlanningAttachmentDescriptors(target),
2321
- });
2322
- const readPlanningAttachment = async (targetType, args) => {
2323
- const inputId = targetType === 'initiative' ? args.initiativeId : args.workstreamId;
2480
+ const buildTaskAttachmentContentPayload = async (task, args) => {
2481
+ const { attachmentId, format = "text", maxChars = 200000 } = args;
2482
+ const attachments = taskAttachmentHelpers.listTaskAttachmentDescriptors(task);
2483
+ const attachment = attachments.find((entry) => entry.attachmentId === attachmentId);
2484
+ if (!attachment) {
2485
+ throw new Error(`Attachment ${attachmentId} not found on task ${task.referenceLabel || task.id}`);
2486
+ }
2487
+ if (format === "metadata") {
2488
+ return {
2489
+ taskId: task.id,
2490
+ attachment,
2491
+ format: "metadata"
2492
+ };
2493
+ }
2494
+ if (format === "download") {
2495
+ return {
2496
+ taskId: task.id,
2497
+ attachment,
2498
+ format: "download",
2499
+ downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
2500
+ reason: attachment.source === 'external' ? 'external_reference' : 'requested_download'
2501
+ };
2502
+ }
2503
+ if (!attachment.readableAsText) {
2504
+ return {
2505
+ taskId: task.id,
2506
+ attachment,
2507
+ format: "download",
2508
+ downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
2509
+ reason: attachment.source === 'external' ? 'external_reference' : 'binary_file'
2510
+ };
2511
+ }
2512
+ const resolvedMaxChars = Math.max(1, Math.floor(Number(maxChars || 200000)));
2513
+ const buffer = await canonicalAssetHelpers.readCanonicalAttachmentBuffer(task.id, attachment);
2514
+ const content = buffer.toString('utf8');
2515
+ if (content.length > resolvedMaxChars) {
2516
+ throw new Error('Attachment exceeds maxChars limit for inline read');
2517
+ }
2518
+ return {
2519
+ taskId: task.id,
2520
+ attachment,
2521
+ format: "text",
2522
+ truncated: false,
2523
+ content
2524
+ };
2525
+ };
2526
+ const buildPlanningAttachmentContentPayload = async (targetType, target, args) => {
2324
2527
  const { attachmentId, format = "text", maxChars = 200000 } = args;
2325
- const target = resolvePlanningAttachmentTarget(targetType, inputId);
2326
2528
  const attachments = listPlanningAttachmentDescriptors(target);
2327
2529
  const attachment = attachments.find((entry) => entry.attachmentId === attachmentId);
2328
2530
  if (!attachment) {
2329
- throw new Error(`Attachment ${attachmentId} not found on ${targetType} ${String(inputId || '').trim() || target.id}`);
2531
+ throw new Error(`Attachment ${attachmentId} not found on ${targetType} ${formatPlanningAttachmentTarget(target)}`);
2330
2532
  }
2331
2533
  if (format === "metadata") {
2332
2534
  return {
2333
- content: [{
2334
- type: "text",
2335
- text: JSON.stringify({
2336
- [`${targetType}Id`]: target.id,
2337
- attachment,
2338
- format: "metadata"
2339
- }, null, 2)
2340
- }],
2535
+ [`${targetType}Id`]: target.id,
2536
+ attachment,
2537
+ format: "metadata"
2341
2538
  };
2342
2539
  }
2343
2540
  if (format === "download") {
2344
2541
  return {
2345
- content: [{
2346
- type: "text",
2347
- text: JSON.stringify({
2348
- [`${targetType}Id`]: target.id,
2349
- attachment,
2350
- format: "download",
2351
- downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
2352
- reason: attachment.source === 'external' ? 'external_reference' : 'requested_download'
2353
- }, null, 2)
2354
- }],
2542
+ [`${targetType}Id`]: target.id,
2543
+ attachment,
2544
+ format: "download",
2545
+ downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
2546
+ reason: attachment.source === 'external' ? 'external_reference' : 'requested_download'
2355
2547
  };
2356
2548
  }
2357
2549
  if (!attachment.readableAsText) {
2358
2550
  return {
2359
- content: [{
2360
- type: "text",
2361
- text: JSON.stringify({
2362
- [`${targetType}Id`]: target.id,
2363
- attachment,
2364
- format: "download",
2365
- downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
2366
- reason: attachment.source === 'external' ? 'external_reference' : 'binary_file'
2367
- }, null, 2)
2368
- }],
2551
+ [`${targetType}Id`]: target.id,
2552
+ attachment,
2553
+ format: "download",
2554
+ downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
2555
+ reason: attachment.source === 'external' ? 'external_reference' : 'binary_file'
2369
2556
  };
2370
2557
  }
2371
2558
  const resolvedMaxChars = Math.max(1, Math.floor(Number(maxChars || 200000)));
@@ -2375,16 +2562,24 @@ export function createTaskforceMcpServer(options = {}) {
2375
2562
  throw new Error('Attachment exceeds maxChars limit for inline read');
2376
2563
  }
2377
2564
  return {
2378
- content: [{
2379
- type: "text",
2380
- text: JSON.stringify({
2381
- [`${targetType}Id`]: target.id,
2382
- attachment,
2383
- format: "text",
2384
- truncated: false,
2385
- content
2386
- }, null, 2)
2387
- }],
2565
+ [`${targetType}Id`]: target.id,
2566
+ attachment,
2567
+ format: "text",
2568
+ truncated: false,
2569
+ content
2570
+ };
2571
+ };
2572
+ const buildPlanningAttachmentListPayload = async (targetType, target, args = {}) => {
2573
+ const attachmentId = typeof args.attachmentId === 'string' && args.attachmentId.trim().length > 0
2574
+ ? args.attachmentId
2575
+ : null;
2576
+ return {
2577
+ [`${targetType}Id`]: target.id,
2578
+ referenceLabel: target.referenceLabel,
2579
+ attachments: listPlanningAttachmentDescriptors(target),
2580
+ ...(attachmentId
2581
+ ? { attachmentContent: await buildPlanningAttachmentContentPayload(targetType, target, { ...args, attachmentId }) }
2582
+ : {}),
2388
2583
  };
2389
2584
  };
2390
2585
  const savePlanningAttachment = async (targetType, args) => {
@@ -2582,11 +2777,12 @@ export function createTaskforceMcpServer(options = {}) {
2582
2777
  case "list_tasks": {
2583
2778
  const startedAtMs = Date.now();
2584
2779
  const { format = "markdown", category, status, openOnly = false, assignee, priority, type, sort = "created", order = "desc", limit } = args;
2780
+ const resolvedAssignee = resolveListTasksAssigneeInput(assignee);
2585
2781
  const slimTasks = core.listTasksSlim(ACTIVE_WORKSPACE_ID, {
2586
2782
  category,
2587
2783
  status,
2588
2784
  openOnly: Boolean(openOnly),
2589
- assignee,
2785
+ assignee: resolvedAssignee,
2590
2786
  priority: priority !== undefined ? Number(priority) : undefined,
2591
2787
  type,
2592
2788
  sort,
@@ -2597,7 +2793,7 @@ export function createTaskforceMcpServer(options = {}) {
2597
2793
  format,
2598
2794
  returnedTasks: slimTasks.length,
2599
2795
  openOnly,
2600
- hasAssigneeFilter: Boolean(assignee),
2796
+ hasAssigneeFilter: Boolean(resolvedAssignee),
2601
2797
  hasStatusFilter: Boolean(status),
2602
2798
  hasCategoryFilter: Boolean(category),
2603
2799
  hasTypeFilter: Boolean(type),
@@ -2645,28 +2841,13 @@ export function createTaskforceMcpServer(options = {}) {
2645
2841
  profileId: resolved.profile.id,
2646
2842
  created: resolved.created
2647
2843
  });
2844
+ const attribution = buildAiProfileAttribution(resolved.profile, 'session');
2648
2845
  return {
2649
2846
  content: [{
2650
2847
  type: "text",
2651
2848
  text: JSON.stringify({
2652
2849
  created: resolved.created,
2653
- profileToken: resolved.profile.profileToken,
2654
- profile: {
2655
- id: resolved.profile.id,
2656
- workspaceId: resolved.profile.workspaceId,
2657
- profileToken: resolved.profile.profileToken,
2658
- name: resolved.profile.name,
2659
- username: resolved.profile.username,
2660
- icon: resolved.profile.icon,
2661
- color: resolved.profile.color,
2662
- description: resolved.profile.description ?? null,
2663
- role: resolved.profile.role ?? null,
2664
- provider: resolved.profile.provider ?? null,
2665
- model: resolved.profile.model ?? null,
2666
- surfaceType: resolved.profile.surfaceType ?? null,
2667
- seatScope: resolved.profile.seatScope ?? null,
2668
- lastActiveAt: resolved.profile.lastActiveAt
2669
- },
2850
+ ...attribution,
2670
2851
  ...(resolved.duplicateWarning && { duplicateWarning: resolved.duplicateWarning })
2671
2852
  }, null, 2)
2672
2853
  }],
@@ -2702,10 +2883,11 @@ export function createTaskforceMcpServer(options = {}) {
2702
2883
  }
2703
2884
  bindingSource = 'token_lookup';
2704
2885
  }
2886
+ const attribution = buildAiProfileAttribution(profile, bindingSource);
2705
2887
  logMcpReadObservation('get_current_profile', startedAtMs, {
2706
- identityBound: Boolean(activeAiProfileId),
2707
- bindingSource,
2708
- writeReady: Boolean(profile),
2888
+ identityBound: attribution.identityBound,
2889
+ bindingSource: attribution.bindingSource,
2890
+ writeReady: attribution.writeReady,
2709
2891
  profileResolved: Boolean(profile),
2710
2892
  usedProfileTokenLookup: !activeAiProfileId && typeof profileToken === 'string' && profileToken.trim().length > 0,
2711
2893
  });
@@ -2714,19 +2896,7 @@ export function createTaskforceMcpServer(options = {}) {
2714
2896
  type: "text",
2715
2897
  text: JSON.stringify({
2716
2898
  workspaceId: ACTIVE_WORKSPACE_ID,
2717
- identityBound: Boolean(activeAiProfileId),
2718
- bindingSource,
2719
- writeReady: Boolean(profile),
2720
- profileToken: profile?.profileToken ?? null,
2721
- profile: profile ? buildAiProfileDescriptor(profile) : null,
2722
- taskFilter: profile ? { assignee: profile.id } : null,
2723
- nextTool: profile ? 'list_tasks' : 'resolve_profile',
2724
- suggestedArgs: profile
2725
- ? { assignee: profile.id, format: 'json' }
2726
- : { name: 'Your AI name' },
2727
- guidance: profile
2728
- ? "Use suggestedArgs with list_tasks to get tasks assigned to this AI profile."
2729
- : "No AI profile is currently bound. Call resolve_profile first, then use the returned profile id as the assignee filter for list_tasks.",
2899
+ ...attribution,
2730
2900
  }, null, 2)
2731
2901
  }],
2732
2902
  };
@@ -2933,92 +3103,78 @@ export function createTaskforceMcpServer(options = {}) {
2933
3103
  content: [{ type: "text", text: JSON.stringify(buildPostWriteWorkstreamResourcePayload(unarchived, 'unarchive_workstream:response'), null, 2) }],
2934
3104
  };
2935
3105
  }
2936
- case "set_task_workstream": {
2937
- const { taskId, workstreamId } = args;
2938
- const { id: resolvedTaskId } = resolveTaskByIdentifierOrThrow(taskId);
2939
- const { id: resolvedWorkstreamId } = resolveWorkstreamByIdentifierOrThrow(workstreamId);
2940
- const updated = core.updateTask(resolvedTaskId, {
2941
- workstreamId: resolvedWorkstreamId,
2942
- }, ACTIVE_WORKSPACE_ID, { actorRef: getActiveMcpActorRef() });
2943
- if (!updated) {
2944
- throw new Error(buildTaskLookupFailureMessage(String(taskId || '').trim()));
3106
+ case "update_task_workstream": {
3107
+ const { id: legacyInputId, taskId, workstreamId } = args;
3108
+ const inputId = taskId ?? legacyInputId;
3109
+ const { id: resolvedTaskId, task } = resolveTaskByIdentifierOrThrow(inputId);
3110
+ if (workstreamId === undefined) {
3111
+ throw new Error('workstreamId is required; pass null or an empty string to unlink the task.');
2945
3112
  }
2946
- recordMcpWriteAudit('set_task_workstream', { taskId: updated.id, workstreamId: resolvedWorkstreamId });
2947
- await notifyResourceMutation({
2948
- includeTasks: true,
2949
- taskIds: [updated.id],
2950
- includeWorkstreams: true,
2951
- workstreamIds: [resolvedWorkstreamId],
2952
- });
2953
- return {
2954
- content: [{
2955
- type: "text",
2956
- text: JSON.stringify({
2957
- task: buildTaskWriteSummary(updated),
2958
- workstreamId: resolvedWorkstreamId,
2959
- }, null, 2)
2960
- }],
2961
- };
2962
- }
2963
- case "clear_task_workstream": {
2964
- const { taskId } = args;
2965
- const { id: resolvedTaskId, task } = resolveTaskByIdentifierOrThrow(taskId);
2966
- const priorWorkstreamId = task.workstreamId || null;
3113
+ const normalizedWorkstreamId = typeof workstreamId === 'string' ? workstreamId.trim() : workstreamId;
3114
+ const nextWorkstreamId = normalizedWorkstreamId === null || normalizedWorkstreamId === ''
3115
+ ? null
3116
+ : resolveWorkstreamByIdentifierOrThrow(normalizedWorkstreamId).id;
3117
+ const previousWorkstreamId = task.workstreamId || null;
2967
3118
  const updated = core.updateTask(resolvedTaskId, {
2968
- workstreamId: null,
3119
+ workstreamId: nextWorkstreamId,
2969
3120
  }, ACTIVE_WORKSPACE_ID, { actorRef: getActiveMcpActorRef() });
2970
3121
  if (!updated) {
2971
- throw new Error(buildTaskLookupFailureMessage(String(taskId || '').trim()));
3122
+ throw new Error(buildTaskLookupFailureMessage(String(inputId || '').trim()));
2972
3123
  }
2973
- recordMcpWriteAudit('clear_task_workstream', { taskId: updated.id, previousWorkstreamId: priorWorkstreamId });
3124
+ const relatedWorkstreamIds = new Set();
3125
+ if (previousWorkstreamId)
3126
+ relatedWorkstreamIds.add(previousWorkstreamId);
3127
+ if (nextWorkstreamId)
3128
+ relatedWorkstreamIds.add(nextWorkstreamId);
3129
+ recordMcpWriteAudit('update_task_workstream', {
3130
+ taskId: updated.id,
3131
+ workstreamId: nextWorkstreamId,
3132
+ previousWorkstreamId,
3133
+ });
2974
3134
  await notifyResourceMutation({
2975
3135
  includeTasks: true,
2976
3136
  taskIds: [updated.id],
2977
- ...(priorWorkstreamId ? { includeWorkstreams: true, workstreamIds: [priorWorkstreamId] } : {}),
3137
+ ...(relatedWorkstreamIds.size > 0 ? { includeWorkstreams: true, workstreamIds: Array.from(relatedWorkstreamIds) } : {}),
2978
3138
  });
2979
3139
  return {
2980
3140
  content: [{
2981
3141
  type: "text",
2982
3142
  text: JSON.stringify({
2983
3143
  task: buildTaskWriteSummary(updated),
2984
- workstreamId: null,
2985
- previousWorkstreamId: priorWorkstreamId,
3144
+ workstreamId: nextWorkstreamId,
3145
+ previousWorkstreamId,
2986
3146
  }, null, 2)
2987
3147
  }],
2988
3148
  };
2989
3149
  }
2990
- case "set_workstream_initiative": {
2991
- const { workstreamId, initiativeId } = args;
2992
- const { id: resolvedWorkstreamId, workstream: existingWorkstream } = resolveWorkstreamByIdentifierOrThrow(workstreamId);
2993
- const { id: resolvedInitiativeId } = resolveInitiativeByIdentifierOrThrow(initiativeId);
3150
+ case "update_workstream_initiative": {
3151
+ const { id: legacyInputId, workstreamId, initiativeId } = args;
3152
+ const inputId = workstreamId ?? legacyInputId;
3153
+ const { id: resolvedWorkstreamId, workstream: existingWorkstream } = resolveWorkstreamByIdentifierOrThrow(inputId);
3154
+ if (initiativeId === undefined) {
3155
+ throw new Error('initiativeId is required; pass null or an empty string to unlink the workstream.');
3156
+ }
3157
+ const normalizedInitiativeId = typeof initiativeId === 'string' ? initiativeId.trim() : initiativeId;
3158
+ const nextInitiativeId = normalizedInitiativeId === null || normalizedInitiativeId === ''
3159
+ ? null
3160
+ : resolveInitiativeByIdentifierOrThrow(normalizedInitiativeId).id;
2994
3161
  const updated = core.updateWorkstream(resolvedWorkstreamId, {
2995
- initiativeId: resolvedInitiativeId,
3162
+ initiativeId: nextInitiativeId,
2996
3163
  }, ACTIVE_WORKSPACE_ID);
2997
- const relatedInitiativeIds = new Set([resolvedInitiativeId]);
3164
+ const relatedInitiativeIds = new Set();
2998
3165
  if (existingWorkstream.initiativeId)
2999
3166
  relatedInitiativeIds.add(existingWorkstream.initiativeId);
3000
- recordMcpWriteAudit('set_workstream_initiative', { workstreamId: updated.id, initiativeId: resolvedInitiativeId });
3001
- await notifyResourceMutation({
3002
- includeWorkstreams: true,
3003
- workstreamIds: [updated.id],
3004
- includeInitiatives: true,
3005
- initiativeIds: Array.from(relatedInitiativeIds),
3167
+ if (nextInitiativeId)
3168
+ relatedInitiativeIds.add(nextInitiativeId);
3169
+ recordMcpWriteAudit('update_workstream_initiative', {
3170
+ workstreamId: updated.id,
3171
+ initiativeId: nextInitiativeId,
3172
+ previousInitiativeId: existingWorkstream.initiativeId || null,
3006
3173
  });
3007
- return {
3008
- content: [{ type: "text", text: JSON.stringify(buildWorkstreamResourcePayload(updated), null, 2) }],
3009
- };
3010
- }
3011
- case "clear_workstream_initiative": {
3012
- const { workstreamId } = args;
3013
- const { id: resolvedWorkstreamId, workstream: existingWorkstream } = resolveWorkstreamByIdentifierOrThrow(workstreamId);
3014
- const updated = core.updateWorkstream(resolvedWorkstreamId, {
3015
- initiativeId: null,
3016
- }, ACTIVE_WORKSPACE_ID);
3017
- recordMcpWriteAudit('clear_workstream_initiative', { workstreamId: updated.id, previousInitiativeId: existingWorkstream.initiativeId || null });
3018
3174
  await notifyResourceMutation({
3019
3175
  includeWorkstreams: true,
3020
3176
  workstreamIds: [updated.id],
3021
- ...(existingWorkstream.initiativeId ? { includeInitiatives: true, initiativeIds: [existingWorkstream.initiativeId] } : {}),
3177
+ ...(relatedInitiativeIds.size > 0 ? { includeInitiatives: true, initiativeIds: Array.from(relatedInitiativeIds) } : {}),
3022
3178
  });
3023
3179
  return {
3024
3180
  content: [{ type: "text", text: JSON.stringify(buildWorkstreamResourcePayload(updated), null, 2) }],
@@ -3079,7 +3235,7 @@ export function createTaskforceMcpServer(options = {}) {
3079
3235
  };
3080
3236
  }
3081
3237
  case "get_task_attachments": {
3082
- const { id, taskId } = args;
3238
+ const { id, taskId, attachmentId } = args;
3083
3239
  const taskIdentifier = taskId ?? id;
3084
3240
  const task = core.resolveTaskIdentifier(taskIdentifier, ACTIVE_WORKSPACE_ID);
3085
3241
  if (!task) {
@@ -3091,77 +3247,10 @@ export function createTaskforceMcpServer(options = {}) {
3091
3247
  text: JSON.stringify({
3092
3248
  taskId: task.id,
3093
3249
  referenceLabel: task.referenceLabel || getTaskReferenceLabel(task),
3094
- attachments: taskAttachmentHelpers.listTaskAttachmentDescriptors(task)
3095
- }, null, 2)
3096
- }],
3097
- };
3098
- }
3099
- case "read_task_attachment": {
3100
- const { taskId, attachmentId, format = "text", maxChars = 200000 } = args;
3101
- const task = core.resolveTaskIdentifier(taskId, ACTIVE_WORKSPACE_ID);
3102
- if (!task) {
3103
- throw new Error(`Task ${taskId} not found`);
3104
- }
3105
- const attachments = taskAttachmentHelpers.listTaskAttachmentDescriptors(task);
3106
- const attachment = attachments.find((entry) => entry.attachmentId === attachmentId);
3107
- if (!attachment) {
3108
- throw new Error(`Attachment ${attachmentId} not found on task ${taskId}`);
3109
- }
3110
- if (format === "metadata") {
3111
- return {
3112
- content: [{
3113
- type: "text",
3114
- text: JSON.stringify({
3115
- taskId: task.id,
3116
- attachment,
3117
- format: "metadata"
3118
- }, null, 2)
3119
- }],
3120
- };
3121
- }
3122
- if (format === "download") {
3123
- return {
3124
- content: [{
3125
- type: "text",
3126
- text: JSON.stringify({
3127
- taskId: task.id,
3128
- attachment,
3129
- format: "download",
3130
- downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
3131
- reason: attachment.source === 'external' ? 'external_reference' : 'requested_download'
3132
- }, null, 2)
3133
- }],
3134
- };
3135
- }
3136
- if (!attachment.readableAsText) {
3137
- return {
3138
- content: [{
3139
- type: "text",
3140
- text: JSON.stringify({
3141
- taskId: task.id,
3142
- attachment,
3143
- format: "download",
3144
- downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
3145
- reason: attachment.source === 'external' ? 'external_reference' : 'binary_file'
3146
- }, null, 2)
3147
- }],
3148
- };
3149
- }
3150
- const resolvedMaxChars = Math.max(1, Math.floor(Number(maxChars || 200000)));
3151
- const buffer = await canonicalAssetHelpers.readCanonicalAttachmentBuffer(task.id, attachment);
3152
- const content = buffer.toString('utf8');
3153
- if (content.length > resolvedMaxChars) {
3154
- throw new Error('Attachment exceeds maxChars limit for inline read');
3155
- }
3156
- return {
3157
- content: [{
3158
- type: "text",
3159
- text: JSON.stringify({
3160
- taskId: task.id,
3161
- attachment,
3162
- format: "text",
3163
- truncated: false,
3164
- content
3250
+ attachments: taskAttachmentHelpers.listTaskAttachmentDescriptors(task),
3251
+ ...(typeof attachmentId === 'string' && attachmentId.trim().length > 0
3252
+ ? { attachmentContent: await buildTaskAttachmentContentPayload(task, args) }
3253
+ : {}),
3165
3254
  }, null, 2)
3166
3255
  }],
3167
3256
  };
@@ -3172,26 +3261,20 @@ export function createTaskforceMcpServer(options = {}) {
3172
3261
  return {
3173
3262
  content: [{
3174
3263
  type: "text",
3175
- text: JSON.stringify(buildPlanningAttachmentListPayload('workstream', target), null, 2)
3264
+ text: JSON.stringify(await buildPlanningAttachmentListPayload('workstream', target, args), null, 2)
3176
3265
  }],
3177
3266
  };
3178
3267
  }
3179
- case "read_workstream_attachment": {
3180
- return readPlanningAttachment('workstream', args);
3181
- }
3182
3268
  case "get_initiative_attachments": {
3183
3269
  const { id } = args;
3184
3270
  const target = resolvePlanningAttachmentTarget('initiative', id);
3185
3271
  return {
3186
3272
  content: [{
3187
3273
  type: "text",
3188
- text: JSON.stringify(buildPlanningAttachmentListPayload('initiative', target), null, 2)
3274
+ text: JSON.stringify(await buildPlanningAttachmentListPayload('initiative', target, args), null, 2)
3189
3275
  }],
3190
3276
  };
3191
3277
  }
3192
- case "read_initiative_attachment": {
3193
- return readPlanningAttachment('initiative', args);
3194
- }
3195
3278
  case "search_images": {
3196
3279
  assertAnnotatedAttachmentReadAccess();
3197
3280
  const { query, limit } = args;
@@ -3298,14 +3381,14 @@ export function createTaskforceMcpServer(options = {}) {
3298
3381
  query: typeof query === 'string' ? query.trim() : '',
3299
3382
  results,
3300
3383
  guidance: results.length > 0
3301
- ? 'After picking a match, call get_document with the suggestedArgs.documentId, then read_document_content to inspect the body.'
3384
+ ? 'After picking a match, call get_document with the suggestedArgs.documentId. Pass includeContent=true when you need the body.'
3302
3385
  : 'No matching canonical documents were found. Try a D-123 reference, document id, title keyword, or filename fragment.',
3303
3386
  }, null, 2)
3304
3387
  }],
3305
3388
  };
3306
3389
  }
3307
3390
  case "get_document": {
3308
- const { id, documentId } = args;
3391
+ const { id, documentId, includeContent, format, maxChars } = args;
3309
3392
  const asset = resolveDocumentAssetByIdentifierOrThrow(documentId ?? id);
3310
3393
  const linkedTasks = (workspaceAssetStore?.listLinksForAsset(asset.assetId, ACTIVE_WORKSPACE_ID) || [])
3311
3394
  .filter((entry) => !entry.deletedAt)
@@ -3331,6 +3414,11 @@ export function createTaskforceMcpServer(options = {}) {
3331
3414
  updatedAt: session.updatedAt,
3332
3415
  commentCount: Array.isArray(session.comments) ? session.comments.length : 0,
3333
3416
  }));
3417
+ const normalizedFormat = typeof format === 'string' ? format.trim().toLowerCase() : '';
3418
+ const shouldIncludeContent = includeContent === true || ['content', 'full', 'text', 'download'].includes(normalizedFormat);
3419
+ const documentContent = shouldIncludeContent
3420
+ ? await buildDocumentContentPayload(asset, { format, maxChars })
3421
+ : null;
3334
3422
  return {
3335
3423
  content: [{
3336
3424
  type: "text",
@@ -3339,83 +3427,46 @@ export function createTaskforceMcpServer(options = {}) {
3339
3427
  readContent: buildDocumentReadHint(asset),
3340
3428
  linkedTasks,
3341
3429
  reviewSessions,
3430
+ ...(documentContent ? { documentContent } : {}),
3342
3431
  }, null, 2)
3343
3432
  }],
3344
3433
  };
3345
3434
  }
3346
- case "read_document_content": {
3347
- const { documentId, format = "text", maxChars = 200000 } = args;
3348
- const asset = resolveDocumentAssetByIdentifierOrThrow(documentId);
3349
- const document = buildDocumentDescriptor(asset);
3350
- if (format === "metadata") {
3351
- return {
3352
- content: [{
3353
- type: "text",
3354
- text: JSON.stringify({
3355
- document,
3356
- format: "metadata"
3357
- }, null, 2)
3358
- }],
3359
- };
3435
+ case "get_image_annotations": {
3436
+ assertAnnotatedAttachmentReadAccess();
3437
+ const { taskId, imageId, imageAssetId, sessionId } = args;
3438
+ const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
3439
+ const normalizedImageIdentifier = typeof imageId === 'string' && imageId.trim()
3440
+ ? imageId.trim()
3441
+ : (typeof imageAssetId === 'string' ? imageAssetId.trim() || null : null);
3442
+ if (!taskId && !normalizedImageIdentifier && !normalizedSessionId) {
3443
+ throw new Error('taskId, imageId, or sessionId is required');
3360
3444
  }
3361
- if (format === "download") {
3445
+ const payload = normalizedSessionId
3446
+ ? core.buildAnnotatedAttachmentPayload({
3447
+ workspaceId: ACTIVE_WORKSPACE_ID,
3448
+ sessionId: normalizedSessionId,
3449
+ })
3450
+ : null;
3451
+ if (!taskId && !normalizedImageIdentifier) {
3362
3452
  return {
3363
3453
  content: [{
3364
3454
  type: "text",
3365
- text: JSON.stringify({
3366
- document,
3367
- format: "download",
3368
- downloadUrl: canonicalAssetHelpers.buildDocumentDownloadUrl(asset),
3369
- reason: "requested_download"
3370
- }, null, 2)
3455
+ text: JSON.stringify({ payload }, null, 2)
3371
3456
  }],
3372
3457
  };
3373
3458
  }
3374
- if (!isTextReadableMimeType(asset.mimeType || '')) {
3375
- return {
3376
- content: [{
3377
- type: "text",
3378
- text: JSON.stringify({
3379
- document,
3380
- format: "download",
3381
- downloadUrl: canonicalAssetHelpers.buildDocumentDownloadUrl(asset),
3382
- reason: "binary_file"
3383
- }, null, 2)
3384
- }],
3385
- };
3386
- }
3387
- const resolvedMaxChars = Math.max(1, Math.floor(Number(maxChars || 200000)));
3388
- const buffer = await canonicalAssetHelpers.readCanonicalDocumentBuffer(asset);
3389
- const content = buffer.toString('utf8');
3390
- if (content.length > resolvedMaxChars) {
3391
- throw new Error('Document exceeds maxChars limit for inline read');
3392
- }
3393
- return {
3394
- content: [{
3395
- type: "text",
3396
- text: JSON.stringify({
3397
- document,
3398
- format: "text",
3399
- truncated: false,
3400
- content
3401
- }, null, 2)
3402
- }],
3403
- };
3404
- }
3405
- case "get_annotated_attachment_sessions": {
3406
- assertAnnotatedAttachmentReadAccess();
3407
- const { taskId, imageId, imageAssetId } = args;
3408
- const normalizedImageIdentifier = typeof imageId === 'string' && imageId.trim()
3409
- ? imageId.trim()
3410
- : (typeof imageAssetId === 'string' ? imageAssetId.trim() || null : null);
3411
- if (!taskId && !normalizedImageIdentifier) {
3412
- throw new Error('taskId or imageId is required');
3413
- }
3414
3459
  const image = normalizedImageIdentifier
3415
3460
  ? resolveImageAssetByIdentifierOrThrow(normalizedImageIdentifier)
3416
3461
  : null;
3417
3462
  if (taskId) {
3418
3463
  const { task } = resolveTaskByIdentifierOrThrow(taskId);
3464
+ if (payload && payload.taskId !== task.id) {
3465
+ throw new Error(`Annotated attachment session ${normalizedSessionId} is not linked to task ${task.referenceLabel || task.id}.`);
3466
+ }
3467
+ if (payload && image && payload.image.assetId !== image.assetId) {
3468
+ throw new Error(`Annotated attachment session ${normalizedSessionId} is not linked to image ${image.assetId}.`);
3469
+ }
3419
3470
  const sessions = core.listAnnotatedAttachmentSessions({
3420
3471
  workspaceId: ACTIVE_WORKSPACE_ID,
3421
3472
  taskId: task.id,
@@ -3428,11 +3479,15 @@ export function createTaskforceMcpServer(options = {}) {
3428
3479
  taskId: task.id,
3429
3480
  referenceLabel: task.referenceLabel || getTaskReferenceLabel(task),
3430
3481
  image: image ? buildImageDescriptor(image) : null,
3431
- sessions
3482
+ sessions,
3483
+ ...(payload ? { payload } : {}),
3432
3484
  }, null, 2)
3433
3485
  }],
3434
3486
  };
3435
3487
  }
3488
+ if (payload && image && payload.image.assetId !== image.assetId) {
3489
+ throw new Error(`Annotated attachment session ${normalizedSessionId} is not linked to image ${image.assetId}.`);
3490
+ }
3436
3491
  const sessionsForImage = core.listAnnotatedAttachmentSessions({
3437
3492
  workspaceId: ACTIVE_WORKSPACE_ID,
3438
3493
  imageAssetId: image.assetId,
@@ -3462,27 +3517,11 @@ export function createTaskforceMcpServer(options = {}) {
3462
3517
  image: buildImageDescriptor(image),
3463
3518
  tasks: sessionsByTask,
3464
3519
  unattachedSessions,
3520
+ ...(payload ? { payload } : {}),
3465
3521
  }, null, 2)
3466
3522
  }],
3467
3523
  };
3468
3524
  }
3469
- case "read_annotated_attachment_payload": {
3470
- assertAnnotatedAttachmentReadAccess();
3471
- const { sessionId } = args;
3472
- if (!sessionId || typeof sessionId !== 'string') {
3473
- throw new Error('sessionId is required');
3474
- }
3475
- const payload = core.buildAnnotatedAttachmentPayload({
3476
- workspaceId: ACTIVE_WORKSPACE_ID,
3477
- sessionId: sessionId.trim(),
3478
- });
3479
- return {
3480
- content: [{
3481
- type: "text",
3482
- text: JSON.stringify(payload, null, 2)
3483
- }],
3484
- };
3485
- }
3486
3525
  case "create_annotated_attachment_session": {
3487
3526
  assertAnnotatedAttachmentReadAccess();
3488
3527
  const { imageId, taskId, title, sessionInstruction } = args;
@@ -3738,9 +3777,37 @@ export function createTaskforceMcpServer(options = {}) {
3738
3777
  }],
3739
3778
  };
3740
3779
  }
3741
- case "get_document_review_sessions": {
3742
- const { documentId } = args;
3743
- const asset = resolveDocumentAssetByIdentifierOrThrow(documentId);
3780
+ case "get_document_reviews": {
3781
+ const { documentId, id: legacyInputId, sessionId } = args;
3782
+ const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
3783
+ if (normalizedSessionId) {
3784
+ const inputId = documentId ?? legacyInputId;
3785
+ const requestedAsset = typeof inputId === 'string' && inputId.trim()
3786
+ ? resolveDocumentAssetByIdentifierOrThrow(inputId)
3787
+ : null;
3788
+ const session = core.getDocumentReviewSession({
3789
+ workspaceId: ACTIVE_WORKSPACE_ID,
3790
+ sessionId: normalizedSessionId,
3791
+ });
3792
+ if (!session) {
3793
+ throw new Error(`Document review session ${sessionId} not found.`);
3794
+ }
3795
+ if (requestedAsset && session.assetId !== requestedAsset.assetId) {
3796
+ throw new Error(`Document review session ${normalizedSessionId} is not linked to document ${requestedAsset.assetId}.`);
3797
+ }
3798
+ const asset = workspaceAssetStore?.get(session.assetId, ACTIVE_WORKSPACE_ID) || null;
3799
+ return {
3800
+ content: [{
3801
+ type: "text",
3802
+ text: JSON.stringify({
3803
+ document: asset ? buildDocumentDescriptor(asset) : null,
3804
+ session
3805
+ }, null, 2)
3806
+ }],
3807
+ };
3808
+ }
3809
+ const inputId = documentId ?? legacyInputId;
3810
+ const asset = resolveDocumentAssetByIdentifierOrThrow(inputId);
3744
3811
  const sessions = core.listDocumentReviewSessions({
3745
3812
  workspaceId: ACTIVE_WORKSPACE_ID,
3746
3813
  assetId: asset.assetId,
@@ -3755,29 +3822,6 @@ export function createTaskforceMcpServer(options = {}) {
3755
3822
  }],
3756
3823
  };
3757
3824
  }
3758
- case "get_document_review_session": {
3759
- const { sessionId } = args;
3760
- if (!sessionId || typeof sessionId !== 'string') {
3761
- throw new Error('sessionId is required');
3762
- }
3763
- const session = core.getDocumentReviewSession({
3764
- workspaceId: ACTIVE_WORKSPACE_ID,
3765
- sessionId: sessionId.trim(),
3766
- });
3767
- if (!session) {
3768
- throw new Error(`Document review session ${sessionId} not found.`);
3769
- }
3770
- const asset = workspaceAssetStore?.get(session.assetId, ACTIVE_WORKSPACE_ID) || null;
3771
- return {
3772
- content: [{
3773
- type: "text",
3774
- text: JSON.stringify({
3775
- document: asset ? buildDocumentDescriptor(asset) : null,
3776
- session
3777
- }, null, 2)
3778
- }],
3779
- };
3780
- }
3781
3825
  case "create_document_review_session": {
3782
3826
  const { documentId, title, status } = args;
3783
3827
  const asset = resolveDocumentAssetByIdentifierOrThrow(documentId);
@@ -4019,51 +4063,60 @@ export function createTaskforceMcpServer(options = {}) {
4019
4063
  content: [{ type: "text", text: JSON.stringify(board, null, 2) }],
4020
4064
  };
4021
4065
  }
4022
- case "schedule_task": {
4023
- const { id: legacyInputId, taskId, scheduledDate: legacyScheduledDate, date, orderInDay: legacyOrderInDay, order } = args;
4066
+ case "update_schedule": {
4067
+ const { id: legacyInputId, taskId, scheduledDate: legacyScheduledDate, date, orderInDay: legacyOrderInDay, order, moves, taskIds, } = args;
4068
+ const inputMoves = Array.isArray(moves)
4069
+ ? moves
4070
+ : Array.isArray(taskIds) && typeof date === 'string'
4071
+ ? taskIds.map((moveTaskId, index) => ({ id: moveTaskId, scheduledDate: date, orderInDay: index }))
4072
+ : null;
4073
+ if (Array.isArray(inputMoves)) {
4074
+ const resolvedMoves = inputMoves.map((move, index) => ({
4075
+ ...move,
4076
+ id: resolveTaskByIdentifierOrThrow(move?.taskId ?? move?.id).id,
4077
+ scheduledDate: move?.date ?? move?.scheduledDate,
4078
+ orderInDay: move?.order ?? move?.orderInDay ?? index,
4079
+ }));
4080
+ const result = core.reorderScheduledTasks(resolvedMoves, ACTIVE_WORKSPACE_ID);
4081
+ recordMcpWriteAudit('update_schedule', {
4082
+ operation: 'reorder',
4083
+ taskIds: resolvedMoves.map((m) => m.id),
4084
+ moveCount: resolvedMoves.length
4085
+ });
4086
+ await notifyResourceMutation({
4087
+ taskIds: resolvedMoves.map((m) => m.id),
4088
+ includeTasks: true,
4089
+ includeStats: true,
4090
+ });
4091
+ return {
4092
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
4093
+ };
4094
+ }
4024
4095
  const inputId = taskId ?? legacyInputId;
4025
- const scheduledDate = date ?? legacyScheduledDate;
4096
+ const scheduleInput = date !== undefined ? date : legacyScheduledDate;
4026
4097
  const orderInDay = order ?? legacyOrderInDay;
4027
4098
  const { id } = resolveTaskByIdentifierOrThrow(inputId);
4028
- if (!scheduledDate || typeof scheduledDate !== 'string')
4029
- throw new Error('scheduledDate is required');
4030
- const result = core.updateTaskSchedule(id, { scheduledDate, orderInDay }, ACTIVE_WORKSPACE_ID);
4031
- if (!result) {
4032
- throw new Error(`Task ${id} not found`);
4099
+ if (scheduleInput === undefined) {
4100
+ throw new Error('date is required; pass null or an empty string to unschedule the task.');
4033
4101
  }
4034
- recordMcpWriteAudit('schedule_task', { taskId: id, scheduledDate, orderInDay: orderInDay ?? null });
4035
- await notifyResourceMutation({
4036
- taskIds: [id],
4037
- includeTasks: true,
4038
- includeStats: true,
4039
- });
4040
- return {
4041
- content: [{
4042
- type: "text",
4043
- text: JSON.stringify({
4044
- success: true,
4045
- scheduling: {
4046
- taskId: id,
4047
- requestedDate: scheduledDate,
4048
- persistedDate: result.task.scheduledDate,
4049
- persistedWeekKey: result.task.scheduledWeekKey,
4050
- persistedOrderInDay: result.task.orderInDay ?? null
4051
- },
4052
- warnings: result.warnings,
4053
- task: buildTaskWriteSummary(result.task)
4054
- }, null, 2)
4055
- }],
4056
- };
4057
- }
4058
- case "unschedule_task": {
4059
- const { id: legacyInputId, taskId } = args;
4060
- const inputId = taskId ?? legacyInputId;
4061
- const { id } = resolveTaskByIdentifierOrThrow(inputId);
4062
- const result = core.updateTaskSchedule(id, { scheduledDate: null, orderInDay: null }, ACTIVE_WORKSPACE_ID);
4102
+ const requestedDate = typeof scheduleInput === 'string' ? scheduleInput.trim() : scheduleInput;
4103
+ const isUnschedule = requestedDate === null || requestedDate === '';
4104
+ if (!isUnschedule && typeof requestedDate !== 'string') {
4105
+ throw new Error('date must be a string, null, or an empty string.');
4106
+ }
4107
+ const result = core.updateTaskSchedule(id, isUnschedule
4108
+ ? { scheduledDate: null, orderInDay: null }
4109
+ : { scheduledDate: requestedDate, orderInDay }, ACTIVE_WORKSPACE_ID);
4063
4110
  if (!result) {
4064
4111
  throw new Error(`Task ${id} not found`);
4065
4112
  }
4066
- recordMcpWriteAudit('unschedule_task', { taskId: id });
4113
+ const operation = isUnschedule ? 'unschedule' : 'schedule';
4114
+ recordMcpWriteAudit('update_schedule', {
4115
+ operation,
4116
+ taskId: id,
4117
+ scheduledDate: isUnschedule ? null : requestedDate,
4118
+ orderInDay: isUnschedule ? null : orderInDay ?? null
4119
+ });
4067
4120
  await notifyResourceMutation({
4068
4121
  taskIds: [id],
4069
4122
  includeTasks: true,
@@ -4074,47 +4127,20 @@ export function createTaskforceMcpServer(options = {}) {
4074
4127
  type: "text",
4075
4128
  text: JSON.stringify({
4076
4129
  success: true,
4130
+ operation,
4077
4131
  scheduling: {
4078
4132
  taskId: id,
4079
- requestedDate: null,
4133
+ requestedDate: isUnschedule ? null : requestedDate,
4080
4134
  persistedDate: result.task.scheduledDate,
4081
4135
  persistedWeekKey: result.task.scheduledWeekKey,
4082
4136
  persistedOrderInDay: result.task.orderInDay ?? null
4083
4137
  },
4084
4138
  warnings: result.warnings,
4085
- task: result.task
4139
+ task: buildTaskWriteSummary(result.task)
4086
4140
  }, null, 2)
4087
4141
  }],
4088
4142
  };
4089
4143
  }
4090
- case "reorder_scheduled_tasks": {
4091
- const { moves, date, taskIds } = args;
4092
- const inputMoves = Array.isArray(moves)
4093
- ? moves
4094
- : Array.isArray(taskIds) && typeof date === 'string'
4095
- ? taskIds.map((taskId, index) => ({ id: taskId, scheduledDate: date, orderInDay: index }))
4096
- : null;
4097
- if (!Array.isArray(inputMoves)) {
4098
- throw new Error('moves must be an array');
4099
- }
4100
- const resolvedMoves = inputMoves.map((move) => ({
4101
- ...move,
4102
- id: resolveTaskByIdentifierOrThrow(move?.taskId ?? move?.id).id
4103
- }));
4104
- const result = core.reorderScheduledTasks(resolvedMoves, ACTIVE_WORKSPACE_ID);
4105
- recordMcpWriteAudit('reorder_scheduled_tasks', {
4106
- taskIds: resolvedMoves.map((m) => m.id),
4107
- moveCount: resolvedMoves.length
4108
- });
4109
- await notifyResourceMutation({
4110
- taskIds: resolvedMoves.map((m) => m.id),
4111
- includeTasks: true,
4112
- includeStats: true,
4113
- });
4114
- return {
4115
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
4116
- };
4117
- }
4118
4144
  case "bulk_carryover": {
4119
4145
  const { fromWeek, toWeek, mode, includeStatuses = ['task', 'in-progress', 'review', 'on-hold'] } = args;
4120
4146
  if (!fromWeek || typeof fromWeek !== 'string')
@@ -4425,96 +4451,6 @@ export function createTaskforceMcpServer(options = {}) {
4425
4451
  throw error;
4426
4452
  }
4427
4453
  }
4428
- case "assign_task": {
4429
- const { id: inputId, assignee } = args;
4430
- const { id } = resolveTaskByIdentifierOrThrow(inputId);
4431
- const resolvedAssignee = resolveRuntimeTaskAssigneeInput(assignee);
4432
- if (!resolvedAssignee)
4433
- throw new Error('assignee is required');
4434
- const updated = core.updateTask(id, { assignee: resolvedAssignee }, ACTIVE_WORKSPACE_ID, { actorRef: getActiveMcpActorRef() });
4435
- if (!updated)
4436
- throw new Error(`Task ${id} not found`);
4437
- recordMcpWriteAudit('assign_task', { taskId: id, assignee: resolvedAssignee });
4438
- await notifyResourceMutation({
4439
- taskIds: [id],
4440
- includeTasks: true,
4441
- includeStats: true,
4442
- });
4443
- return {
4444
- content: [{
4445
- type: "text",
4446
- text: JSON.stringify({
4447
- task: buildTaskWriteSummary(updated),
4448
- assignee: resolvedAssignee,
4449
- }, null, 2)
4450
- }],
4451
- };
4452
- }
4453
- case "bulk_assign_tasks": {
4454
- const { ids, assignee } = args;
4455
- const list = Array.isArray(ids) ? ids : [];
4456
- if (list.length === 0)
4457
- throw new Error('ids array is required');
4458
- const resolvedAssignee = resolveRuntimeTaskAssigneeInput(assignee);
4459
- if (!resolvedAssignee)
4460
- throw new Error('assignee is required');
4461
- const resolvedIds = list.map((id) => resolveTaskByIdentifierOrThrow(id).id);
4462
- const updates = resolvedIds.map((id) => ({ id, assignee: resolvedAssignee }));
4463
- const preview = core.bulkUpdateFieldsPreview(updates, ACTIVE_WORKSPACE_ID);
4464
- const changed = [];
4465
- const errors = [];
4466
- for (const skipped of preview.skipped) {
4467
- if (skipped.statusCode >= 400) {
4468
- errors.push({
4469
- index: skipped.index,
4470
- id: skipped.id,
4471
- message: skipped.message,
4472
- statusCode: skipped.statusCode,
4473
- code: skipped.code
4474
- });
4475
- }
4476
- }
4477
- for (const item of preview.toApply) {
4478
- const updated = core.updateTask(item.id, item.fields, ACTIVE_WORKSPACE_ID, { actorRef: getActiveMcpActorRef() });
4479
- if (!updated) {
4480
- errors.push({ index: item.index, id: item.id, message: 'Task not found', statusCode: 404, code: 'TASK_NOT_FOUND' });
4481
- continue;
4482
- }
4483
- changed.push({ id: updated.id, assignee: updated.assignee || 'unassigned' });
4484
- }
4485
- const summary = buildBatchSummary({
4486
- operation: 'bulk-assign-tasks',
4487
- total: resolvedIds.length,
4488
- succeeded: changed.length
4489
- });
4490
- recordMcpWriteAudit('bulk_assign_tasks', {
4491
- assignee: resolvedAssignee,
4492
- requested: resolvedIds.length,
4493
- changed: changed.length
4494
- });
4495
- await notifyResourceMutation({
4496
- taskIds: resolvedIds,
4497
- includeTasks: true,
4498
- includeStats: true,
4499
- });
4500
- return {
4501
- content: [{
4502
- type: "text",
4503
- text: JSON.stringify({
4504
- success: errors.length === 0,
4505
- summary,
4506
- results: {
4507
- assigned: changed,
4508
- preflight: {
4509
- deduped: preview.deduped,
4510
- skipped_noop: preview.skipped.filter((s) => s.code === 'NO_CHANGES' || s.code === 'DUPLICATE_SUPERSEDED').length
4511
- }
4512
- },
4513
- errors
4514
- }, null, 2)
4515
- }],
4516
- };
4517
- }
4518
4454
  case "archive_task": {
4519
4455
  const { id: inputId, status, reason } = args;
4520
4456
  const { id, task } = resolveTaskByIdentifierOrThrow(inputId);
@@ -4531,15 +4467,33 @@ export function createTaskforceMcpServer(options = {}) {
4531
4467
  };
4532
4468
  }
4533
4469
  case "get_config": {
4470
+ const startedAtMs = Date.now();
4471
+ const categories = getActiveCategories();
4472
+ const types = getActiveTypes();
4473
+ const priorities = getActivePriorities();
4474
+ const approaches = getActiveApproaches();
4475
+ logMcpReadObservation('get_config', startedAtMs, {
4476
+ categoryCount: categories.length,
4477
+ typeCount: types.length,
4478
+ priorityCount: priorities.length,
4479
+ approachCount: approaches.length,
4480
+ activeToolProfile,
4481
+ });
4534
4482
  return {
4535
4483
  content: [{
4536
4484
  type: "text",
4537
4485
  text: JSON.stringify({
4538
- categories: getActiveCategories(),
4539
- types: getActiveTypes(),
4540
- priorities: getActivePriorities(),
4541
- approaches: getActiveApproaches(),
4542
- complexities: COMPLEXITY_OPTIONS
4486
+ categories,
4487
+ types,
4488
+ priorities,
4489
+ approaches,
4490
+ complexities: COMPLEXITY_OPTIONS,
4491
+ mcpToolProfile: {
4492
+ active: activeToolProfile,
4493
+ available: ['default', 'extended', 'full'],
4494
+ defaultForRuntime: defaultToolProfile,
4495
+ guidance: 'Cloud sessions default to the default profile. Local/dev sessions default to full. Use TASKFORCE_MCP_TOOL_PROFILE=default|extended|full for local stdio, or pass an explicit toolProfile runtime option where supported.',
4496
+ }
4543
4497
  }, null, 2)
4544
4498
  }],
4545
4499
  };
@@ -5090,18 +5044,6 @@ export function createTaskforceMcpServer(options = {}) {
5090
5044
  case "delete_initiative_attachment": {
5091
5045
  return deletePlanningAttachment('initiative', args);
5092
5046
  }
5093
- case "list_archive": {
5094
- const { format = "markdown" } = args;
5095
- const archive = core.listArchive(ACTIVE_WORKSPACE_ID);
5096
- if (format === "json") {
5097
- return {
5098
- content: [{ type: "text", text: JSON.stringify(archive.archived, null, 2) }],
5099
- };
5100
- }
5101
- return {
5102
- content: [{ type: "text", text: renderTaskTable(archive.archived) }],
5103
- };
5104
- }
5105
5047
  case "unarchive_task": {
5106
5048
  const { id: inputId } = args;
5107
5049
  const { id, task: existingTask } = resolveTaskByIdentifierOrThrow(inputId, { notFoundMessage: buildTaskLookupFailureMessage(String(inputId || '').trim()) });
@@ -5188,18 +5130,6 @@ export function createTaskforceMcpServer(options = {}) {
5188
5130
  content: [{ type: "text", text: "Task types updated" }],
5189
5131
  };
5190
5132
  }
5191
- case "bulk_update_type": {
5192
- const { fromType, toType } = args;
5193
- const count = core.bulkUpdateType(fromType, toType, ACTIVE_WORKSPACE_ID);
5194
- recordMcpWriteAudit('bulk_update_type', { fromType, toType, updated: count });
5195
- await notifyResourceMutation({
5196
- includeTasks: true,
5197
- includeStats: true,
5198
- });
5199
- return {
5200
- content: [{ type: "text", text: `Updated ${count} items from ${fromType} to ${toType}` }],
5201
- };
5202
- }
5203
5133
  case "bulk_update_fields": {
5204
5134
  const { updates } = args;
5205
5135
  const list = Array.isArray(updates) ? updates : [];
@@ -5334,6 +5264,7 @@ export function createTaskforceMcpServer(options = {}) {
5334
5264
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
5335
5265
  const { name, arguments: args } = request.params;
5336
5266
  try {
5267
+ assertToolProfile(name);
5337
5268
  assertToolPermission(name);
5338
5269
  ensureResolvedProfileForTool(name, args);
5339
5270
  assertResolvedProfileForMutatingTool(name);
@@ -5365,6 +5296,7 @@ export function createTaskforceMcpServer(options = {}) {
5365
5296
  workspaceId: ACTIVE_WORKSPACE_ID,
5366
5297
  tenantId,
5367
5298
  version: MCP_SERVER_VERSION,
5299
+ toolProfile: activeToolProfile,
5368
5300
  };
5369
5301
  }
5370
5302
  export async function startTaskforceMcpStdioServer(options = {}) {