tascan-mcp 3.5.0 → 3.7.0

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 (2) hide show
  1. package/package.json +1 -1
  2. package/tools.cjs +255 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tascan-mcp",
3
- "version": "3.5.0",
3
+ "version": "3.7.0",
4
4
  "mcpName": "io.github.snowbikemike/tascan-mcp",
5
5
  "description": "TaScan MCP Server — Closed-loop autonomous operations protocol. 50 tools for projects, tasks, workers, QR codes, NFC tags, geofenced zones, AI condition assessment, worker passports, verification-gated gig payments, cross-entity search, duplicate-worker detection, AI issue analysis, and autonomous remediation dispatch. 14 patents filed (435+ claims). Task. Scan. Done.",
6
6
  "type": "module",
package/tools.cjs CHANGED
@@ -252,8 +252,25 @@ const TOOLS = [
252
252
  title: { type: 'string', description: 'Task title' },
253
253
  description: { type: 'string', description: 'Task description' },
254
254
  response_type: { type: 'string', enum: ['checkbox', 'photo', 'text', 'number', 'date', 'choice'], description: 'CRITICAL: "text" for names, phones, emails, notes, addresses, any free-form input. "photo" for tasks needing photographic proof (inspections, serial numbers, packed cases). "checkbox" ONLY for simple yes/no confirmations. "number" for numeric values. "date" for dates. "choice" for multiple-choice (needs response_config.options). Most info-collection tasks should be "text", most verification tasks should be "photo".' },
255
+ response_config: { type: 'object', description: 'Response configuration. For "choice": {options: ["A","B","C"]}. For "text": {placeholder, multiline}. For "number": {placeholder}. For "date": {label, include_text}.' },
255
256
  is_safety_checkpoint: { type: 'boolean', description: 'Safety-critical task flag' },
256
- requires_photo: { type: 'boolean', description: 'Require photo on completion' }
257
+ requires_photo: { type: 'boolean', description: 'Require photo on completion' },
258
+ sort_order: { type: 'number', description: 'Explicit sort position (defaults to end of list, in array order)' },
259
+ required_equipment: { type: 'string', description: 'Equipment this task depends on (free text, e.g. "88 lb kettlebells")' },
260
+ subtasks: {
261
+ type: 'array',
262
+ items: {
263
+ type: 'object',
264
+ properties: {
265
+ title: { type: 'string', description: 'Subtask title (e.g. "Set 1")' },
266
+ response_type: { type: 'string', enum: ['checkbox', 'number', 'text', 'choice'], description: 'Typed subtask response. "number" for per-set values (reps/weight), "text" for notes, "choice" needs response_config.options. Default checkbox.' },
267
+ response_config: { type: 'object', description: 'Same shape as task response_config' },
268
+ requires_photo: { type: 'boolean' }
269
+ },
270
+ required: ['title']
271
+ },
272
+ description: 'Subtasks created with the task. Example set logging: [{title:"Set 1",response_type:"number"},{title:"Set 2",response_type:"number"},{title:"Set 3",response_type:"number"}]'
273
+ }
257
274
  },
258
275
  required: ['title']
259
276
  },
@@ -386,9 +403,11 @@ const TOOLS = [
386
403
  title: { type: 'string', description: 'New title' },
387
404
  description: { type: 'string', description: 'New description' },
388
405
  response_type: { type: 'string', enum: ['checkbox', 'photo', 'text', 'number', 'date', 'choice'], description: 'See tascan_add_tasks for guidance. "text" for info collection, "photo" for visual proof, "checkbox" for yes/no only.' },
406
+ response_config: { type: 'object', description: 'Response configuration. For "choice": {options: [...]}. See tascan_add_tasks.' },
389
407
  is_safety_checkpoint: { type: 'boolean', description: 'Safety-critical flag' },
390
408
  requires_photo: { type: 'boolean', description: 'Require photo' },
391
- sort_order: { type: 'number', description: 'Sort position' }
409
+ sort_order: { type: 'number', description: 'Sort position' },
410
+ required_equipment: { type: 'string', description: 'Equipment this task depends on (free text)' }
392
411
  },
393
412
  required: ['task_id']
394
413
  },
@@ -400,6 +419,157 @@ const TOOLS = [
400
419
  return `Task updated: ${result.data.title}\n\n${JSON.stringify(result.data, null, 2)}`;
401
420
  }
402
421
  },
422
+ {
423
+ name: 'tascan_reply_with_list',
424
+ description: 'Reply to a task list WITH a task list — the two-way tasking primitive. Creates a new list linked into the parent\'s thread, aimed back at whoever sent the original (e.g. "Grant access — pick a window" with response_type date, or an info request with response_type text). The org gets pinged; the thread shows in both the worker portal and Simple Mode. Use tascan_get_thread-style follow-up via tascan_list_projects/tascan_get_report to read answers.',
425
+ inputSchema: {
426
+ type: 'object',
427
+ properties: {
428
+ parent_list_id: { type: 'string', description: 'The list being replied to' },
429
+ title: { type: 'string', description: 'Reply list title (e.g. "Before I can start...")' },
430
+ author_name: { type: 'string', description: 'Who is replying (shown in the thread)' },
431
+ tasks: {
432
+ type: 'array',
433
+ items: {
434
+ type: 'object',
435
+ properties: {
436
+ title: { type: 'string' },
437
+ description: { type: 'string' },
438
+ response_type: { type: 'string', enum: ['checkbox', 'text', 'number', 'date', 'choice', 'photo'], description: '"date" for scheduling windows, "text" for info requests, "choice" needs response_config.options' },
439
+ response_config: { type: 'object' }
440
+ },
441
+ required: ['title']
442
+ },
443
+ description: 'Items the recipient answers — each becomes a typed task'
444
+ }
445
+ },
446
+ required: ['parent_list_id', 'title', 'tasks']
447
+ },
448
+ annotations: { title: 'Reply With a List', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
449
+ handler: async (args) => {
450
+ const resp = await fetch('https://app.tascan.io/.netlify/functions/list-reply', {
451
+ method: 'POST',
452
+ headers: { 'Content-Type': 'application/json' },
453
+ body: JSON.stringify({
454
+ parent_list_id: args.parent_list_id,
455
+ title: args.title,
456
+ author_name: args.author_name,
457
+ tasks: args.tasks
458
+ })
459
+ });
460
+ const data = await resp.json();
461
+ if (!resp.ok) throw new Error(data.error || 'Reply failed');
462
+ return `Reply list created and linked into the thread!\n List: ${args.title} (${data.list_id})\n Thread: ${data.thread_id}\n Tasks: ${data.task_count}\n Worker link: ${data.worker_url}\nThe org has been pinged via app_messages.`;
463
+ }
464
+ },
465
+ {
466
+ name: 'tascan_add_subtasks',
467
+ description: 'Add one or more subtasks to a task (bulk). Subtasks support typed responses: "number" for per-set data (reps, weight, distance), "text" for notes, "choice" for options, "checkbox" for simple steps. Set-logging example: task "Bench Press" with subtasks Set 1/Set 2/Set 3 each response_type "number" — each completed set stores its value and timestamp, giving per-set timing for progression tracking.',
468
+ inputSchema: {
469
+ type: 'object',
470
+ properties: {
471
+ task_id: { type: 'string', description: 'Parent task ID' },
472
+ subtasks: {
473
+ type: 'array',
474
+ items: {
475
+ type: 'object',
476
+ properties: {
477
+ title: { type: 'string', description: 'Subtask title (e.g. "Set 1")' },
478
+ description: { type: 'string', description: 'Optional detail' },
479
+ response_type: { type: 'string', enum: ['checkbox', 'number', 'text', 'choice'], description: 'Default checkbox. "number" for per-set values. "choice" needs response_config.options.' },
480
+ response_config: { type: 'object', description: 'For "choice": {options: [...]}. For "number"/"text": {placeholder}.' },
481
+ requires_photo: { type: 'boolean', description: 'Require photo on completion' },
482
+ sort_order: { type: 'number', description: 'Explicit position (defaults to end, in array order)' }
483
+ },
484
+ required: ['title']
485
+ },
486
+ description: 'Array of subtasks to create'
487
+ }
488
+ },
489
+ required: ['task_id', 'subtasks']
490
+ },
491
+ annotations: { title: 'Add Subtasks', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
492
+ handler: async (args, api) => {
493
+ const result = await api('POST', `/tasks/${args.task_id}/subtasks`, args.subtasks);
494
+ return `${result.count} subtask(s) created on task ${args.task_id}\n\n${JSON.stringify(result.data, null, 2)}`;
495
+ }
496
+ },
497
+ {
498
+ name: 'tascan_list_subtasks',
499
+ description: 'List the subtasks of a task, including completion state, stored response values, and completion timestamps (per-set timing).',
500
+ inputSchema: {
501
+ type: 'object',
502
+ properties: { task_id: { type: 'string', description: 'Parent task ID' } },
503
+ required: ['task_id']
504
+ },
505
+ annotations: { title: 'List Subtasks', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
506
+ handler: async (args, api) => {
507
+ const result = await api('GET', `/tasks/${args.task_id}/subtasks`);
508
+ return JSON.stringify(result.data, null, 2);
509
+ }
510
+ },
511
+ {
512
+ name: 'tascan_update_subtask',
513
+ description: 'Update a subtask (title, description, response_type, response_config, requires_photo, sort_order).',
514
+ inputSchema: {
515
+ type: 'object',
516
+ properties: {
517
+ subtask_id: { type: 'string', description: 'Subtask ID' },
518
+ title: { type: 'string' },
519
+ description: { type: 'string' },
520
+ response_type: { type: 'string', enum: ['checkbox', 'number', 'text', 'choice'] },
521
+ response_config: { type: 'object' },
522
+ requires_photo: { type: 'boolean' },
523
+ sort_order: { type: 'number' }
524
+ },
525
+ required: ['subtask_id']
526
+ },
527
+ annotations: { title: 'Update Subtask', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
528
+ handler: async (args, api) => {
529
+ const { subtask_id, ...updates } = args;
530
+ const body = Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined));
531
+ const result = await api('PUT', `/subtasks/${subtask_id}`, body);
532
+ return `Subtask updated: ${result.data.title}\n\n${JSON.stringify(result.data, null, 2)}`;
533
+ }
534
+ },
535
+ {
536
+ name: 'tascan_complete_subtask',
537
+ description: 'Complete a subtask, optionally recording a typed response_value (e.g. the weight or reps for that set). Each completion is timestamped, so consecutive set completions yield per-set durations. Returns progress including all_subtasks_complete — when true, complete the parent task with tascan_complete_task.',
538
+ inputSchema: {
539
+ type: 'object',
540
+ properties: {
541
+ subtask_id: { type: 'string', description: 'Subtask ID to complete' },
542
+ worker_id: { type: 'string', description: 'Worker performing the completion (optional)' },
543
+ response_value: { type: 'string', description: 'Typed response value (for number/text/choice subtasks), e.g. "165"' },
544
+ notes: { type: 'string', description: 'Optional notes' }
545
+ },
546
+ required: ['subtask_id']
547
+ },
548
+ annotations: { title: 'Complete Subtask', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
549
+ handler: async (args, api) => {
550
+ const result = await api('POST', `/subtasks/${args.subtask_id}/complete`, {
551
+ worker_id: args.worker_id,
552
+ response_value: args.response_value,
553
+ notes: args.notes
554
+ });
555
+ const d = result.data;
556
+ return `Subtask completed!\n ${d.subtask_title}${d.response_value ? ' = ' + d.response_value : ''}\n Task: ${d.task_title}\n Progress: ${d.subtasks_done}/${d.subtasks_total}${d.all_subtasks_complete ? '\n ALL SUBTASKS COMPLETE — parent task is ready to complete.' : ''}`;
557
+ }
558
+ },
559
+ {
560
+ name: 'tascan_delete_subtask',
561
+ description: 'Delete a subtask and its completions. This action is irreversible.',
562
+ inputSchema: {
563
+ type: 'object',
564
+ properties: { subtask_id: { type: 'string', description: 'Subtask ID to delete' } },
565
+ required: ['subtask_id']
566
+ },
567
+ annotations: { title: 'Delete Subtask', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
568
+ handler: async (args, api) => {
569
+ await api('DELETE', `/subtasks/${args.subtask_id}`);
570
+ return `Subtask ${args.subtask_id} deleted successfully.`;
571
+ }
572
+ },
403
573
  {
404
574
  name: 'tascan_delete_task',
405
575
  description: 'Delete a specific task and its completions. This action is irreversible.',
@@ -571,7 +741,7 @@ const TOOLS = [
571
741
  },
572
742
  {
573
743
  name: 'tascan_query_responses',
574
- description: "Query one task's submitted responses across every list in a project — e.g. the same exercise repeated across many workout lists returns one chronological progression series instead of N report lookups. Match by task title pattern or exact task ID.",
744
+ description: "Query one task's submitted responses across every list in a project — e.g. the same exercise repeated across many workout lists returns one chronological progression series instead of N report lookups. Match by task title pattern or exact task ID. Subtask completions interleave into the same series labeled 'Task › Subtask' (e.g. per-set values Set 1/2/3 with their own timestamps), so set-level progression chains across lists automatically.",
575
745
  inputSchema: {
576
746
  type: 'object',
577
747
  properties: {
@@ -592,7 +762,8 @@ const TOOLS = [
592
762
  const { value: v0, note } = fmtResponse(resp.response_value, resp.notes);
593
763
  const value = (v0 == null && resp.photo_url) ? '[photo submitted]' : (v0 != null && resp.photo_url) ? v0 + ' [+photo]' : v0;
594
764
  const when = (resp.completed_at || '').slice(0, 16).replace('T', ' ');
595
- text += `${when} · ${resp.list_name} · ${resp.task_title}: ${value != null ? value : '(no value)'}`;
765
+ const label = resp.subtask_title ? `${resp.task_title} › ${resp.subtask_title}` : resp.task_title;
766
+ text += `${when} · ${resp.list_name} · ${label}: ${value != null ? value : '(no value)'}`;
596
767
  if (note) text += ` — ${note}`;
597
768
  text += ` (${resp.worker_name})\n`;
598
769
  }
@@ -896,31 +1067,97 @@ const TOOLS = [
896
1067
  },
897
1068
  {
898
1069
  name: 'tascan_get_scan_history',
899
- description: 'Get scan event history for a registered NFC tag — timestamps, GPS coordinates, and accuracy for each scan',
1070
+ description: 'Scan accountability data. Two modes: (1) tag_id — scans of a registered NFC tag; (2) task_list_id or project_id — every QR/link page-open stamp: when the code was scanned, GPS + IP + channel (qr/nfc/sms/email/link), who the scanner turned out to be, and the scan→start delta (how long between scanning and actually identifying + starting work — the sign-in-and-vanish metric).',
900
1071
  inputSchema: {
901
1072
  type: 'object',
902
1073
  properties: {
903
- tag_id: { type: 'string', description: 'Tag registry ID (from tascan_list_tags)' },
904
- limit: { type: 'number', description: 'Max results (default 50)' }
1074
+ tag_id: { type: 'string', description: 'NFC tag registry ID (from tascan_list_tags) — tag mode' },
1075
+ task_list_id: { type: 'string', description: 'Task list ID — page-scan mode' },
1076
+ project_id: { type: 'string', description: 'Project ID — page-scan mode, all lists in the project' },
1077
+ source: { type: 'string', description: 'Filter page scans by channel: qr | nfc | sms | email | link | unknown' },
1078
+ since: { type: 'string', description: 'ISO timestamp — only scans after this' },
1079
+ limit: { type: 'number', description: 'Max results (default 50, max 200)' }
905
1080
  },
906
- required: ['tag_id']
1081
+ required: []
907
1082
  },
908
1083
  annotations: { title: 'Get Scan History', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
909
1084
  handler: async (args, api) => {
910
- const path = `/tags/${args.tag_id}/scans` + (args.limit ? `?limit=${args.limit}` : '');
911
- const result = await api('GET', path);
912
- const scans = result.data;
913
- if (!scans.length) return 'No scans recorded for this tag.';
914
- let text = `${scans.length} scan(s):\n\n`;
1085
+ if (args.tag_id) {
1086
+ const path = `/tags/${args.tag_id}/scans` + (args.limit ? `?limit=${args.limit}` : '');
1087
+ const result = await api('GET', path);
1088
+ const scans = result.data;
1089
+ if (!scans.length) return 'No scans recorded for this tag.';
1090
+ let text = `${scans.length} tag scan(s):\n\n`;
1091
+ scans.forEach((s, i) => {
1092
+ text += `${i + 1}. ${s.scanned_at}`;
1093
+ if (s.gps_lat && s.gps_lng) text += ` — GPS: ${s.gps_lat.toFixed(6)}, ${s.gps_lng.toFixed(6)} (±${Math.round(s.gps_accuracy || 0)}m)`;
1094
+ text += '\n';
1095
+ });
1096
+ return text;
1097
+ }
1098
+ if (!args.task_list_id && !args.project_id) {
1099
+ return 'Provide tag_id (NFC tag mode) or task_list_id / project_id (page-scan mode).';
1100
+ }
1101
+ const qs = new URLSearchParams();
1102
+ if (args.task_list_id) qs.set('task_list_id', args.task_list_id);
1103
+ else qs.set('project_id', args.project_id);
1104
+ if (args.source) qs.set('source', args.source);
1105
+ if (args.since) qs.set('since', args.since);
1106
+ if (args.limit) qs.set('limit', String(args.limit));
1107
+ const result = await api('GET', '/scans?' + qs.toString());
1108
+ const scans = result.data || [];
1109
+ if (!scans.length) return 'No page scans recorded yet for this scope.';
1110
+ const converted = scans.filter(s => s.converted);
1111
+ const deltas = converted.map(s => s.scan_to_start_secs).filter(n => n != null);
1112
+ const median = deltas.length ? deltas.sort((a, b) => a - b)[Math.floor(deltas.length / 2)] : null;
1113
+ let text = `${scans.length} scan(s) — ${converted.length} converted to a working session (${Math.round(converted.length / scans.length * 100)}%)`;
1114
+ if (median != null) text += ` · median scan→start ${median}s`;
1115
+ text += '\n\n';
915
1116
  scans.forEach((s, i) => {
916
- text += `${i + 1}. ${s.scanned_at}`;
917
- if (s.gps_lat && s.gps_lng) text += ` — GPS: ${s.gps_lat.toFixed(6)}, ${s.gps_lng.toFixed(6)} (±${Math.round(s.gps_accuracy || 0)}m)`;
1117
+ text += `${i + 1}. ${s.scanned_at} · ${s.scan_source || 'unknown'}`;
1118
+ if (s.worker_name) text += ` · ${s.worker_name}`;
1119
+ text += s.converted ? ` · started +${s.scan_to_start_secs}s` : ' · NEVER STARTED';
1120
+ if (s.ip) text += ` · ip ${s.ip}`;
1121
+ if (s.gps_lat && s.gps_lng) text += ` · GPS ${s.gps_lat.toFixed(5)}, ${s.gps_lng.toFixed(5)}`;
918
1122
  text += '\n';
919
1123
  });
920
1124
  return text;
921
1125
  }
922
1126
  },
923
1127
 
1128
+ // ─── Worker Marketplace (Patent 4: portable reputation) ──────────
1129
+ {
1130
+ name: 'tascan_search_marketplace',
1131
+ description: 'Search the cross-org Worker Marketplace: workers who opted in (discoverable=true on their passport), ranked by passkey trust tier + verified completion volume. Skills are AI-inferred from REAL completed work, not resumes — each carries a verified_task_count and a civilian_equivalent job title. Returns sanitized public cards only (first name + last initial, skills, stats, passport URL) — never phone, email, or org membership.',
1132
+ inputSchema: {
1133
+ type: 'object',
1134
+ properties: {
1135
+ q: { type: 'string', description: 'Skill, category, name, or civilian job title — e.g. "forklift", "LED wall", "AV technician"' },
1136
+ min_completions: { type: 'number', description: 'Only workers with at least this many verified completions' },
1137
+ limit: { type: 'number', description: 'Max cards (default 25, max 50)' }
1138
+ },
1139
+ required: []
1140
+ },
1141
+ annotations: { title: 'Search Worker Marketplace', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1142
+ handler: async (args, api) => {
1143
+ const qs = new URLSearchParams();
1144
+ if (args.q) qs.set('q', args.q);
1145
+ if (args.min_completions) qs.set('min_completions', String(args.min_completions));
1146
+ if (args.limit) qs.set('limit', String(args.limit));
1147
+ const result = await api('GET', '/marketplace' + (qs.toString() ? '?' + qs.toString() : ''));
1148
+ const cards = result.data || [];
1149
+ if (!cards.length) return 'No discoverable workers match. Workers appear after opting in on their own passport (profile page → "List me").';
1150
+ return cards.map(w => {
1151
+ let t = `${w.name}${w.passkey_verified ? ' ✓ID-VERIFIED' : ''} — ${w.completions} verified completions · ${w.points} pts · since ${w.member_since}`;
1152
+ if (w.skills.length) t += '\n Skills: ' + w.skills.map(s => `${s.skill} (${s.verified_task_count}×)`).join(', ');
1153
+ const civ = [...new Set(w.skills.map(s => s.civilian_equivalent).filter(Boolean))];
1154
+ if (civ.length) t += '\n Civilian equivalent: ' + civ.join(' · ');
1155
+ t += '\n Passport: ' + w.passport_url;
1156
+ return t;
1157
+ }).join('\n\n');
1158
+ }
1159
+ },
1160
+
924
1161
  // ─── Geofenced Zones (Patent 7: location-triggered delivery) ─────
925
1162
  {
926
1163
  name: 'tascan_create_zone',
@@ -940,6 +1177,7 @@ const TOOLS = [
940
1177
  auto_clock_in: { type: 'boolean', description: 'Entering the zone writes a shift_start clock-in event' },
941
1178
  auto_clock_out: { type: 'boolean', description: 'Leaving the zone writes a shift_end clock-out event' },
942
1179
  notify_email: { type: 'string', description: 'Alert recipient override — defaults to all org admins' },
1180
+ polygon: { type: 'array', items: { type: 'array', items: { type: 'number' } }, description: 'Polygon/rectangle zone instead of a circle: vertices as [[lat,lng], ...], at least 3. lat/lng/radius_m are then computed (centroid + bounding radius) — still pass lat/lng but they are overridden.' },
943
1181
  description: { type: 'string', description: 'Shown to workers on the Site Gate page' }
944
1182
  },
945
1183
  required: ['name', 'lat', 'lng']
@@ -984,7 +1222,8 @@ const TOOLS = [
984
1222
  enforce_on_list: { type: 'boolean' }, is_active: { type: 'boolean' },
985
1223
  notify_on_enter: { type: 'boolean' }, notify_on_exit: { type: 'boolean' },
986
1224
  auto_clock_in: { type: 'boolean' }, auto_clock_out: { type: 'boolean' },
987
- notify_email: { type: 'string' }
1225
+ notify_email: { type: 'string' },
1226
+ polygon: { type: 'array', items: { type: 'array', items: { type: 'number' } }, description: 'Replace geometry with a polygon ([[lat,lng],...], ≥3 vertices); pass null to revert to a circle' }
988
1227
  },
989
1228
  required: ['zone_id']
990
1229
  },