tascan-mcp 3.4.0 → 3.6.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 +90 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tascan-mcp",
3
- "version": "3.4.0",
3
+ "version": "3.6.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
@@ -896,31 +896,97 @@ const TOOLS = [
896
896
  },
897
897
  {
898
898
  name: 'tascan_get_scan_history',
899
- description: 'Get scan event history for a registered NFC tag — timestamps, GPS coordinates, and accuracy for each scan',
899
+ 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
900
  inputSchema: {
901
901
  type: 'object',
902
902
  properties: {
903
- tag_id: { type: 'string', description: 'Tag registry ID (from tascan_list_tags)' },
904
- limit: { type: 'number', description: 'Max results (default 50)' }
903
+ tag_id: { type: 'string', description: 'NFC tag registry ID (from tascan_list_tags) — tag mode' },
904
+ task_list_id: { type: 'string', description: 'Task list ID — page-scan mode' },
905
+ project_id: { type: 'string', description: 'Project ID — page-scan mode, all lists in the project' },
906
+ source: { type: 'string', description: 'Filter page scans by channel: qr | nfc | sms | email | link | unknown' },
907
+ since: { type: 'string', description: 'ISO timestamp — only scans after this' },
908
+ limit: { type: 'number', description: 'Max results (default 50, max 200)' }
905
909
  },
906
- required: ['tag_id']
910
+ required: []
907
911
  },
908
912
  annotations: { title: 'Get Scan History', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
909
913
  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`;
914
+ if (args.tag_id) {
915
+ const path = `/tags/${args.tag_id}/scans` + (args.limit ? `?limit=${args.limit}` : '');
916
+ const result = await api('GET', path);
917
+ const scans = result.data;
918
+ if (!scans.length) return 'No scans recorded for this tag.';
919
+ let text = `${scans.length} tag scan(s):\n\n`;
920
+ scans.forEach((s, i) => {
921
+ text += `${i + 1}. ${s.scanned_at}`;
922
+ 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)`;
923
+ text += '\n';
924
+ });
925
+ return text;
926
+ }
927
+ if (!args.task_list_id && !args.project_id) {
928
+ return 'Provide tag_id (NFC tag mode) or task_list_id / project_id (page-scan mode).';
929
+ }
930
+ const qs = new URLSearchParams();
931
+ if (args.task_list_id) qs.set('task_list_id', args.task_list_id);
932
+ else qs.set('project_id', args.project_id);
933
+ if (args.source) qs.set('source', args.source);
934
+ if (args.since) qs.set('since', args.since);
935
+ if (args.limit) qs.set('limit', String(args.limit));
936
+ const result = await api('GET', '/scans?' + qs.toString());
937
+ const scans = result.data || [];
938
+ if (!scans.length) return 'No page scans recorded yet for this scope.';
939
+ const converted = scans.filter(s => s.converted);
940
+ const deltas = converted.map(s => s.scan_to_start_secs).filter(n => n != null);
941
+ const median = deltas.length ? deltas.sort((a, b) => a - b)[Math.floor(deltas.length / 2)] : null;
942
+ let text = `${scans.length} scan(s) — ${converted.length} converted to a working session (${Math.round(converted.length / scans.length * 100)}%)`;
943
+ if (median != null) text += ` · median scan→start ${median}s`;
944
+ text += '\n\n';
915
945
  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)`;
946
+ text += `${i + 1}. ${s.scanned_at} · ${s.scan_source || 'unknown'}`;
947
+ if (s.worker_name) text += ` · ${s.worker_name}`;
948
+ text += s.converted ? ` · started +${s.scan_to_start_secs}s` : ' · NEVER STARTED';
949
+ if (s.ip) text += ` · ip ${s.ip}`;
950
+ if (s.gps_lat && s.gps_lng) text += ` · GPS ${s.gps_lat.toFixed(5)}, ${s.gps_lng.toFixed(5)}`;
918
951
  text += '\n';
919
952
  });
920
953
  return text;
921
954
  }
922
955
  },
923
956
 
957
+ // ─── Worker Marketplace (Patent 4: portable reputation) ──────────
958
+ {
959
+ name: 'tascan_search_marketplace',
960
+ 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.',
961
+ inputSchema: {
962
+ type: 'object',
963
+ properties: {
964
+ q: { type: 'string', description: 'Skill, category, name, or civilian job title — e.g. "forklift", "LED wall", "AV technician"' },
965
+ min_completions: { type: 'number', description: 'Only workers with at least this many verified completions' },
966
+ limit: { type: 'number', description: 'Max cards (default 25, max 50)' }
967
+ },
968
+ required: []
969
+ },
970
+ annotations: { title: 'Search Worker Marketplace', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
971
+ handler: async (args, api) => {
972
+ const qs = new URLSearchParams();
973
+ if (args.q) qs.set('q', args.q);
974
+ if (args.min_completions) qs.set('min_completions', String(args.min_completions));
975
+ if (args.limit) qs.set('limit', String(args.limit));
976
+ const result = await api('GET', '/marketplace' + (qs.toString() ? '?' + qs.toString() : ''));
977
+ const cards = result.data || [];
978
+ if (!cards.length) return 'No discoverable workers match. Workers appear after opting in on their own passport (profile page → "List me").';
979
+ return cards.map(w => {
980
+ let t = `${w.name}${w.passkey_verified ? ' ✓ID-VERIFIED' : ''} — ${w.completions} verified completions · ${w.points} pts · since ${w.member_since}`;
981
+ if (w.skills.length) t += '\n Skills: ' + w.skills.map(s => `${s.skill} (${s.verified_task_count}×)`).join(', ');
982
+ const civ = [...new Set(w.skills.map(s => s.civilian_equivalent).filter(Boolean))];
983
+ if (civ.length) t += '\n Civilian equivalent: ' + civ.join(' · ');
984
+ t += '\n Passport: ' + w.passport_url;
985
+ return t;
986
+ }).join('\n\n');
987
+ }
988
+ },
989
+
924
990
  // ─── Geofenced Zones (Patent 7: location-triggered delivery) ─────
925
991
  {
926
992
  name: 'tascan_create_zone',
@@ -935,6 +1001,12 @@ const TOOLS = [
935
1001
  project_id: { type: 'string', description: 'Project this zone belongs to' },
936
1002
  task_list_id: { type: 'string', description: 'Task list the Site Gate routes workers to when they are inside this zone' },
937
1003
  enforce_on_list: { type: 'boolean', description: 'Zone-lock the task list — it cannot be started from outside the radius' },
1004
+ notify_on_enter: { type: 'boolean', description: 'Email the manager when a worker enters this zone (danger areas)' },
1005
+ notify_on_exit: { type: 'boolean', description: 'Email the manager when a worker leaves this zone (accountability — sign in then disappear)' },
1006
+ auto_clock_in: { type: 'boolean', description: 'Entering the zone writes a shift_start clock-in event' },
1007
+ auto_clock_out: { type: 'boolean', description: 'Leaving the zone writes a shift_end clock-out event' },
1008
+ notify_email: { type: 'string', description: 'Alert recipient override — defaults to all org admins' },
1009
+ 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.' },
938
1010
  description: { type: 'string', description: 'Shown to workers on the Site Gate page' }
939
1011
  },
940
1012
  required: ['name', 'lat', 'lng']
@@ -943,7 +1015,7 @@ const TOOLS = [
943
1015
  handler: async (args, api) => {
944
1016
  const result = await api('POST', '/zones', args);
945
1017
  const z = result.data;
946
- let text = `Zone created: ${z.name}\n\nID: ${z.id}\nCenter: ${z.lat}, ${z.lng}\nRadius: ${z.radius_m}m\nRoutes to list: ${z.task_list_id || '(none — set task_list_id to enable Site Gate routing)'}\nZone-locked: ${z.enforce_on_list ? 'YES — list cannot start outside the zone' : 'no'}`;
1018
+ let text = `Zone created: ${z.name}\n\nID: ${z.id}\nCenter: ${z.lat}, ${z.lng}\nRadius: ${z.radius_m}m\nRoutes to list: ${z.task_list_id || '(none — set task_list_id to enable Site Gate routing)'}\nZone-locked: ${z.enforce_on_list ? 'YES — list cannot start outside the zone' : 'no'}\nAlerts: enter=${z.notify_on_enter ? 'ON' : 'off'} exit=${z.notify_on_exit ? 'ON' : 'off'} · Auto-clock: in=${z.auto_clock_in ? 'ON' : 'off'} out=${z.auto_clock_out ? 'ON' : 'off'}`;
947
1019
  if (z.project_id) text += `\n\nSite Gate URL (print this QR at the site entrance):\nhttps://app.tascan.io/geo.html?project=${z.project_id}`;
948
1020
  return text;
949
1021
  }
@@ -963,7 +1035,7 @@ const TOOLS = [
963
1035
  const zones = result.data || [];
964
1036
  if (!zones.length) return 'No zones configured. Use tascan_create_zone to add one.';
965
1037
  return zones.map(z =>
966
- `${z.name} (${z.id})\n Center: ${z.lat}, ${z.lng} · Radius: ${z.radius_m}m · ${z.is_active ? 'active' : 'INACTIVE'}\n Routes to: ${z.task_list_id || '—'} · Zone-locked: ${z.enforce_on_list ? 'yes' : 'no'}`
1038
+ `${z.name} (${z.id})\n Center: ${z.lat}, ${z.lng} · Radius: ${z.radius_m}m · ${z.is_active ? 'active' : 'INACTIVE'}\n Routes to: ${z.task_list_id || '—'} · Zone-locked: ${z.enforce_on_list ? 'yes' : 'no'}\n Alerts: enter=${z.notify_on_enter ? 'ON' : 'off'} exit=${z.notify_on_exit ? 'ON' : 'off'} · Auto-clock: in=${z.auto_clock_in ? 'ON' : 'off'} out=${z.auto_clock_out ? 'ON' : 'off'}`
967
1039
  ).join('\n\n');
968
1040
  }
969
1041
  },
@@ -976,7 +1048,11 @@ const TOOLS = [
976
1048
  zone_id: { type: 'string', description: 'Zone ID' },
977
1049
  name: { type: 'string' }, lat: { type: 'number' }, lng: { type: 'number' },
978
1050
  radius_m: { type: 'number' }, task_list_id: { type: 'string' },
979
- enforce_on_list: { type: 'boolean' }, is_active: { type: 'boolean' }
1051
+ enforce_on_list: { type: 'boolean' }, is_active: { type: 'boolean' },
1052
+ notify_on_enter: { type: 'boolean' }, notify_on_exit: { type: 'boolean' },
1053
+ auto_clock_in: { type: 'boolean' }, auto_clock_out: { type: 'boolean' },
1054
+ notify_email: { type: 'string' },
1055
+ 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' }
980
1056
  },
981
1057
  required: ['zone_id']
982
1058
  },