tascan-mcp 3.1.0 → 3.3.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 +2 -2
  2. package/tools.cjs +239 -0
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "tascan-mcp",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "mcpName": "io.github.snowbikemike/tascan-mcp",
5
- "description": "TaScan MCP Server — Closed-loop autonomous operations protocol. 36 tools for projects, tasks, workers, QR codes, NFC tag registry, scan verification, AI issue analysis, and autonomous remediation dispatch. 14 patents filed (435+ claims). Task. Scan. Done.",
5
+ "description": "TaScan MCP Server — Closed-loop autonomous operations protocol. 46 tools for projects, tasks, workers, QR codes, NFC tags, geofenced zones, AI condition assessment, worker passports, verification-gated gig payments, AI issue analysis, and autonomous remediation dispatch. 14 patents filed (435+ claims). Task. Scan. Done.",
6
6
  "type": "module",
7
7
  "main": "index.js",
8
8
  "bin": {
package/tools.cjs CHANGED
@@ -917,6 +917,245 @@ const TOOLS = [
917
917
  });
918
918
  return text;
919
919
  }
920
+ },
921
+
922
+ // ─── Geofenced Zones (Patent 7: location-triggered delivery) ─────
923
+ {
924
+ name: 'tascan_create_zone',
925
+ description: 'Create a geofenced work zone. Delivery zones route workers who open the project Site Gate (geo.html?project=...) to this zone\'s task list when GPS places them inside the radius. Set enforce_on_list=true to zone-lock the task list — workers cannot start it from outside the zone.',
926
+ inputSchema: {
927
+ type: 'object',
928
+ properties: {
929
+ name: { type: 'string', description: 'Zone name (e.g. "Stage Left", "Loading Dock")' },
930
+ lat: { type: 'number', description: 'Zone center latitude' },
931
+ lng: { type: 'number', description: 'Zone center longitude' },
932
+ radius_m: { type: 'number', description: 'Radius in meters (default 150, min 10, max 100000)' },
933
+ project_id: { type: 'string', description: 'Project this zone belongs to' },
934
+ task_list_id: { type: 'string', description: 'Task list the Site Gate routes workers to when they are inside this zone' },
935
+ enforce_on_list: { type: 'boolean', description: 'Zone-lock the task list — it cannot be started from outside the radius' },
936
+ description: { type: 'string', description: 'Shown to workers on the Site Gate page' }
937
+ },
938
+ required: ['name', 'lat', 'lng']
939
+ },
940
+ annotations: { title: 'Create Geofenced Zone', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
941
+ handler: async (args, api) => {
942
+ const result = await api('POST', '/zones', args);
943
+ const z = result.data;
944
+ 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'}`;
945
+ 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}`;
946
+ return text;
947
+ }
948
+ },
949
+ {
950
+ name: 'tascan_list_zones',
951
+ description: 'List geofenced work zones, optionally filtered by project. Shows center, radius, routing target, and zone-lock status.',
952
+ inputSchema: {
953
+ type: 'object',
954
+ properties: { project_id: { type: 'string', description: 'Filter by project' } },
955
+ required: []
956
+ },
957
+ annotations: { title: 'List Geofenced Zones', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
958
+ handler: async (args, api) => {
959
+ const path = '/zones' + (args.project_id ? `?project_id=${args.project_id}` : '');
960
+ const result = await api('GET', path);
961
+ const zones = result.data || [];
962
+ if (!zones.length) return 'No zones configured. Use tascan_create_zone to add one.';
963
+ return zones.map(z =>
964
+ `${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'}`
965
+ ).join('\n\n');
966
+ }
967
+ },
968
+ {
969
+ name: 'tascan_update_zone',
970
+ description: 'Update a geofenced zone — move the center, resize the radius, change the routing target, toggle zone-lock, or deactivate it (is_active=false).',
971
+ inputSchema: {
972
+ type: 'object',
973
+ properties: {
974
+ zone_id: { type: 'string', description: 'Zone ID' },
975
+ name: { type: 'string' }, lat: { type: 'number' }, lng: { type: 'number' },
976
+ radius_m: { type: 'number' }, task_list_id: { type: 'string' },
977
+ enforce_on_list: { type: 'boolean' }, is_active: { type: 'boolean' }
978
+ },
979
+ required: ['zone_id']
980
+ },
981
+ annotations: { title: 'Update Geofenced Zone', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
982
+ handler: async (args, api) => {
983
+ const { zone_id, ...updates } = args;
984
+ const result = await api('PUT', `/zones/${zone_id}`, updates);
985
+ return `Zone updated:\n\n${JSON.stringify(result.data, null, 2)}`;
986
+ }
987
+ },
988
+
989
+ // ─── Condition Ledger (Patent 12: longitudinal asset condition) ──
990
+ {
991
+ name: 'tascan_register_asset',
992
+ description: 'Register a physical asset (equipment, structure, vehicle, machine) in the condition ledger so it can be assessed over time. Each asset gets a longitudinal condition history with AI scoring and degradation trajectory.',
993
+ inputSchema: {
994
+ type: 'object',
995
+ properties: {
996
+ name: { type: 'string', description: 'Asset name (e.g. "LED Wall Processor #3")' },
997
+ serial_number: { type: 'string', description: 'Serial number or asset tag (unique per org)' },
998
+ asset_type: { type: 'string', description: 'Type (e.g. "LED processor", "forklift", "scaffold")' },
999
+ description: { type: 'string', description: 'Context the AI assessor should know' },
1000
+ location_description: { type: 'string' },
1001
+ project_id: { type: 'string' }
1002
+ },
1003
+ required: ['name']
1004
+ },
1005
+ annotations: { title: 'Register Condition Asset', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1006
+ handler: async (args, api) => {
1007
+ const result = await api('POST', '/assets', args);
1008
+ const a = result.data;
1009
+ return `Asset registered: ${a.name}\n\nID: ${a.id}${a.serial_number ? '\nSerial: ' + a.serial_number : ''}\n\nUse tascan_assess_condition with a photo URL to record its first condition assessment.`;
1010
+ }
1011
+ },
1012
+ {
1013
+ name: 'tascan_assess_condition',
1014
+ description: 'Run an AI condition assessment of an asset from a photo. The model scores 0-100 with the asset\'s full assessment history in context, so it reads degradation over time — returning the Condition Delta Score vs the previous assessment, defects, wear indicators, maintenance recommendations, and a degradation trajectory. Sensor-free predictive maintenance.',
1015
+ inputSchema: {
1016
+ type: 'object',
1017
+ properties: {
1018
+ asset_id: { type: 'string', description: 'Asset ID (from tascan_register_asset or tascan_list_assets)' },
1019
+ photo_url: { type: 'string', description: 'Public URL of the assessment photo' },
1020
+ worker_name: { type: 'string', description: 'Who took the photo (optional)' }
1021
+ },
1022
+ required: ['asset_id', 'photo_url']
1023
+ },
1024
+ annotations: { title: 'Assess Asset Condition', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1025
+ handler: async (args) => {
1026
+ const resp = await fetch('https://app.tascan.io/api/assess-condition', {
1027
+ method: 'POST',
1028
+ headers: { 'Content-Type': 'application/json' },
1029
+ body: JSON.stringify({ asset_id: args.asset_id, photo_url: args.photo_url, worker_name: args.worker_name || null, source: 'api' })
1030
+ });
1031
+ const data = await resp.json();
1032
+ if (!resp.ok) return `Assessment failed: ${data.error || resp.status}`;
1033
+ const a = data.assessment;
1034
+ const f = a.findings || {};
1035
+ let text = `Condition Assessment — ${data.asset.name}\n\n`;
1036
+ text += `Score: ${a.condition_score ?? 'N/A'}/100 (${a.condition_grade || 'ungraded'})`;
1037
+ if (data.delta_score != null) text += ` · Delta vs previous: ${data.delta_score > 0 ? '+' : ''}${data.delta_score}`;
1038
+ text += `\nTrajectory: ${f.trajectory || 'unknown'}${f.trajectory_note ? ' — ' + f.trajectory_note : ''}\n`;
1039
+ if (f.defects?.length) text += `\nDefects:\n${f.defects.map(d => ' - ' + d).join('\n')}\n`;
1040
+ if (f.wear_indicators?.length) text += `\nWear indicators:\n${f.wear_indicators.map(d => ' - ' + d).join('\n')}\n`;
1041
+ if (f.recommendations?.length) text += `\nRecommendations:\n${f.recommendations.map(d => ' - ' + d).join('\n')}\n`;
1042
+ text += `\nSummary: ${a.summary || '—'}\nHistory depth: ${data.history_depth} prior assessment(s)`;
1043
+ return text;
1044
+ }
1045
+ },
1046
+ {
1047
+ name: 'tascan_condition_history',
1048
+ description: 'Get an asset\'s longitudinal condition history — score trend over time, every assessment with grade, delta, findings, and who assessed it. The per-serial-number condition ledger.',
1049
+ inputSchema: {
1050
+ type: 'object',
1051
+ properties: { asset_id: { type: 'string', description: 'Asset ID' } },
1052
+ required: ['asset_id']
1053
+ },
1054
+ annotations: { title: 'Get Condition History', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1055
+ handler: async (args, api) => {
1056
+ const result = await api('GET', `/assets/${args.asset_id}`);
1057
+ const a = result.data;
1058
+ let text = `${a.name}${a.serial_number ? ' · SN ' + a.serial_number : ''}\n`;
1059
+ text += `Latest score: ${a.latest_score ?? 'never assessed'}\n`;
1060
+ if (a.score_trend?.length > 1) text += `Trend (oldest→newest): ${a.score_trend.join(' → ')}\n`;
1061
+ text += `\n${(a.assessments || []).length} assessment(s):\n`;
1062
+ (a.assessments || []).forEach(s => {
1063
+ text += `\n${s.created_at.slice(0, 16).replace('T', ' ')} — ${s.condition_score ?? '?'}/100 (${s.condition_grade || '?'})`;
1064
+ if (s.delta_score != null) text += ` [${s.delta_score > 0 ? '+' : ''}${s.delta_score}]`;
1065
+ if (s.summary) text += `\n ${s.summary}`;
1066
+ });
1067
+ return text;
1068
+ }
1069
+ },
1070
+
1071
+ {
1072
+ name: 'tascan_list_assets',
1073
+ description: 'List registered condition-ledger assets with their latest condition scores. Use to recover an asset_id for tascan_assess_condition or tascan_condition_history.',
1074
+ inputSchema: {
1075
+ type: 'object',
1076
+ properties: { project_id: { type: 'string', description: 'Filter by project' } },
1077
+ required: []
1078
+ },
1079
+ annotations: { title: 'List Condition Assets', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1080
+ handler: async (args, api) => {
1081
+ const path = '/assets' + (args.project_id ? `?project_id=${args.project_id}` : '');
1082
+ const result = await api('GET', path);
1083
+ const assets = result.data || [];
1084
+ if (!assets.length) return 'No assets registered. Use tascan_register_asset to add one.';
1085
+ return assets.map(a =>
1086
+ `${a.name} (${a.id})${a.serial_number ? '\n SN: ' + a.serial_number : ''}${a.asset_type ? ' · ' + a.asset_type : ''}${a.location_description ? '\n Location: ' + a.location_description : ''}`
1087
+ ).join('\n\n');
1088
+ }
1089
+ },
1090
+
1091
+ // ─── Gig Payments (verification-gated direct payer→worker) ───────
1092
+ {
1093
+ name: 'tascan_request_payment',
1094
+ description: 'Pledge a payment on a task list: when the list is verified complete (every task done + photo evidence on photo-required tasks), the payer automatically receives a Stripe pay link that routes the money DIRECTLY to the worker (0% TaScan fee). No money moves and no card is stored at pledge time. The worker must have completed payout onboarding (Get Paid on their profile).',
1095
+ inputSchema: {
1096
+ type: 'object',
1097
+ properties: {
1098
+ task_list_id: { type: 'string', description: 'Task list the payment is tied to' },
1099
+ worker_id: { type: 'string', description: 'Worker who gets paid' },
1100
+ amount_cents: { type: 'number', description: 'Amount in cents ($1 min, $10,000 max)' },
1101
+ payer_email: { type: 'string', description: 'Who pays — receives the pay link on verification' },
1102
+ payer_name: { type: 'string' },
1103
+ memo: { type: 'string', description: 'What the payment is for (shown to the payer)' }
1104
+ },
1105
+ required: ['task_list_id', 'worker_id', 'amount_cents', 'payer_email']
1106
+ },
1107
+ annotations: { title: 'Request Verified Payment', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1108
+ handler: async (args, api) => {
1109
+ const result = await api('POST', '/payments', args);
1110
+ const p = result.data;
1111
+ return `Payment pledged: $${(p.amount_cents / 100).toFixed(2)} to worker ${p.worker_id}\n\nID: ${p.id}\nStatus: ${p.status}\nPayer: ${p.payer_email}\n\n${result.note || 'Pay link is issued automatically when the list is verified complete.'}`;
1112
+ }
1113
+ },
1114
+ {
1115
+ name: 'tascan_list_payments',
1116
+ description: 'List gig payments and their lifecycle status: awaiting_completion (pledged, work not verified yet), ready_to_pay (verified — pay link sent to payer), paid, canceled. Filter by task list or status.',
1117
+ inputSchema: {
1118
+ type: 'object',
1119
+ properties: {
1120
+ task_list_id: { type: 'string', description: 'Filter by task list' },
1121
+ status: { type: 'string', enum: ['awaiting_completion', 'ready_to_pay', 'paid', 'canceled'], description: 'Filter by status' }
1122
+ },
1123
+ required: []
1124
+ },
1125
+ annotations: { title: 'List Gig Payments', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1126
+ handler: async (args, api) => {
1127
+ const qs = [];
1128
+ if (args.task_list_id) qs.push(`task_list_id=${args.task_list_id}`);
1129
+ if (args.status) qs.push(`status=${args.status}`);
1130
+ const result = await api('GET', '/payments' + (qs.length ? '?' + qs.join('&') : ''));
1131
+ const payments = result.data || [];
1132
+ if (!payments.length) return 'No payments found.';
1133
+ return payments.map(p =>
1134
+ `$${(p.amount_cents / 100).toFixed(2)} — ${p.status.toUpperCase()}\n ID: ${p.id}\n List: ${p.task_list_id}\n Payer: ${p.payer_email}${p.memo ? '\n Memo: ' + p.memo : ''}${p.paid_at ? '\n Paid: ' + p.paid_at : p.released_at ? '\n Link issued: ' + p.released_at : ''}`
1135
+ ).join('\n\n');
1136
+ }
1137
+ },
1138
+
1139
+ // ─── Worker Passport (Patent 4 foundation) ───────────────────────
1140
+ {
1141
+ name: 'tascan_get_worker_passport',
1142
+ description: 'Get a worker\'s verified work passport — task counts, lists worked, photos submitted, GPS-verified hours, points, streaks, and earned merit badges, all computed from real completion data (not self-reported). Includes the shareable profile URL.',
1143
+ inputSchema: {
1144
+ type: 'object',
1145
+ properties: { worker_id: { type: 'string', description: 'Worker ID' } },
1146
+ required: ['worker_id']
1147
+ },
1148
+ annotations: { title: 'Get Worker Passport', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1149
+ handler: async (args, api) => {
1150
+ const result = await api('GET', `/workers/${args.worker_id}/passport`);
1151
+ const p = result.data;
1152
+ const s = p.stats;
1153
+ return `Worker Passport — ${p.worker.name}\n\n` +
1154
+ `Tasks completed: ${s.total_tasks}\nLists worked: ${s.total_lists}\nPhotos submitted: ${s.photos_submitted}\n` +
1155
+ `Verified hours: ${s.verified_hours}\nPoints: ${s.total_points} · Streak: ${s.current_streak} (best ${s.longest_streak})\n` +
1156
+ `Member since: ${(s.member_since || '').slice(0, 10)}\n\n` +
1157
+ `Badges: ${p.badges.length ? p.badges.join(' · ') : 'none yet'}\n\nShareable profile: ${p.profile_url}`;
1158
+ }
920
1159
  }
921
1160
  ];
922
1161