tascan-mcp 3.3.0 → 3.5.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 +100 -5
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "tascan-mcp",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "mcpName": "io.github.snowbikemike/tascan-mcp",
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.",
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",
7
7
  "main": "index.js",
8
8
  "bin": {
package/tools.cjs CHANGED
@@ -560,7 +560,8 @@ const TOOLS = [
560
560
  text += '\n';
561
561
  if (args.include_responses && task.responses && task.responses.length > 0) {
562
562
  for (const resp of task.responses) {
563
- const { value, note } = fmtResponse(resp.response_value, resp.notes);
563
+ const { value: v0, note } = fmtResponse(resp.response_value, resp.notes);
564
+ const value = (v0 == null && resp.photo_url) ? '[photo submitted]' : (v0 != null && resp.photo_url) ? v0 + ' [+photo]' : v0;
564
565
  text += ` -> ${value != null ? value : '(no value)'}${note ? ' — ' + note : ''} (${resp.worker_name}, ${resp.completed_at})\n`;
565
566
  }
566
567
  }
@@ -588,7 +589,8 @@ const TOOLS = [
588
589
  const r = result.data;
589
590
  let text = `Responses for "${r.task}" in project "${r.project_name}" (${r.matched_tasks} matching task(s), ${r.responses.length} response(s), oldest first):\n\n`;
590
591
  for (const resp of r.responses) {
591
- const { value, note } = fmtResponse(resp.response_value, resp.notes);
592
+ const { value: v0, note } = fmtResponse(resp.response_value, resp.notes);
593
+ const value = (v0 == null && resp.photo_url) ? '[photo submitted]' : (v0 != null && resp.photo_url) ? v0 + ' [+photo]' : v0;
592
594
  const when = (resp.completed_at || '').slice(0, 16).replace('T', ' ');
593
595
  text += `${when} · ${resp.list_name} · ${resp.task_title}: ${value != null ? value : '(no value)'}`;
594
596
  if (note) text += ` — ${note}`;
@@ -933,6 +935,11 @@ const TOOLS = [
933
935
  project_id: { type: 'string', description: 'Project this zone belongs to' },
934
936
  task_list_id: { type: 'string', description: 'Task list the Site Gate routes workers to when they are inside this zone' },
935
937
  enforce_on_list: { type: 'boolean', description: 'Zone-lock the task list — it cannot be started from outside the radius' },
938
+ notify_on_enter: { type: 'boolean', description: 'Email the manager when a worker enters this zone (danger areas)' },
939
+ notify_on_exit: { type: 'boolean', description: 'Email the manager when a worker leaves this zone (accountability — sign in then disappear)' },
940
+ auto_clock_in: { type: 'boolean', description: 'Entering the zone writes a shift_start clock-in event' },
941
+ auto_clock_out: { type: 'boolean', description: 'Leaving the zone writes a shift_end clock-out event' },
942
+ notify_email: { type: 'string', description: 'Alert recipient override — defaults to all org admins' },
936
943
  description: { type: 'string', description: 'Shown to workers on the Site Gate page' }
937
944
  },
938
945
  required: ['name', 'lat', 'lng']
@@ -941,7 +948,7 @@ const TOOLS = [
941
948
  handler: async (args, api) => {
942
949
  const result = await api('POST', '/zones', args);
943
950
  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'}`;
951
+ 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'}`;
945
952
  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
953
  return text;
947
954
  }
@@ -961,7 +968,7 @@ const TOOLS = [
961
968
  const zones = result.data || [];
962
969
  if (!zones.length) return 'No zones configured. Use tascan_create_zone to add one.';
963
970
  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'}`
971
+ `${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'}`
965
972
  ).join('\n\n');
966
973
  }
967
974
  },
@@ -974,7 +981,10 @@ const TOOLS = [
974
981
  zone_id: { type: 'string', description: 'Zone ID' },
975
982
  name: { type: 'string' }, lat: { type: 'number' }, lng: { type: 'number' },
976
983
  radius_m: { type: 'number' }, task_list_id: { type: 'string' },
977
- enforce_on_list: { type: 'boolean' }, is_active: { type: 'boolean' }
984
+ enforce_on_list: { type: 'boolean' }, is_active: { type: 'boolean' },
985
+ notify_on_enter: { type: 'boolean' }, notify_on_exit: { type: 'boolean' },
986
+ auto_clock_in: { type: 'boolean' }, auto_clock_out: { type: 'boolean' },
987
+ notify_email: { type: 'string' }
978
988
  },
979
989
  required: ['zone_id']
980
990
  },
@@ -1156,6 +1166,91 @@ const TOOLS = [
1156
1166
  `Member since: ${(s.member_since || '').slice(0, 10)}\n\n` +
1157
1167
  `Badges: ${p.badges.length ? p.badges.join(' · ') : 'none yet'}\n\nShareable profile: ${p.profile_url}`;
1158
1168
  }
1169
+ },
1170
+
1171
+ // ─── Read-layer integrity (from the 2026-08-18 MCP audit) ────────
1172
+ {
1173
+ name: 'tascan_server_info',
1174
+ description: 'Identify exactly which TaScan server and schema this MCP session is talking to. Call this FIRST when diagnosing anything — it makes "dev server masquerading as production" and "is my fix deployed yet" one tool call instead of an inference.',
1175
+ inputSchema: { type: 'object', properties: {}, required: [] },
1176
+ annotations: { title: 'Server Info', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1177
+ handler: async (args, api) => {
1178
+ let pkgVersion = 'unknown';
1179
+ try { pkgVersion = require('./package.json').version; } catch (e) {}
1180
+ let meta = null;
1181
+ try { meta = (await api('GET', '/meta')).data; } catch (e) { meta = { error: String(e.message || e) }; }
1182
+ return `TaScan MCP Server Info\n\n` +
1183
+ `tascan-mcp package: v${pkgVersion} (${TOOLS.length} tools)\n` +
1184
+ `API service: ${meta?.service || 'UNREACHABLE'}\n` +
1185
+ `Host: ${meta?.host || '?'} · Environment: ${meta?.environment || '?'}\n` +
1186
+ `Org: ${meta?.org_id || '?'}\n` +
1187
+ `DB schema: ${meta?.schema_version || '?'}\n` +
1188
+ `Server time: ${meta?.server_time || '?'}` +
1189
+ (meta?.error ? `\nAPI error: ${meta.error}` : '');
1190
+ }
1191
+ },
1192
+ {
1193
+ name: 'tascan_find',
1194
+ description: 'Cross-entity search: find projects, task lists, tasks, workers, or condition assets by name in one call — with ids and parent context to disambiguate. Use this instead of walking projects→lists→tasks or guessing ids from display names.',
1195
+ inputSchema: {
1196
+ type: 'object',
1197
+ properties: {
1198
+ query: { type: 'string', description: 'Search text (min 2 chars, case-insensitive substring)' },
1199
+ type: { type: 'string', enum: ['project', 'list', 'task', 'worker', 'asset'], description: 'Optional: restrict to one entity type' }
1200
+ },
1201
+ required: ['query']
1202
+ },
1203
+ annotations: { title: 'Find Anything', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1204
+ handler: async (args, api) => {
1205
+ let path = `/find?q=${encodeURIComponent(args.query)}`;
1206
+ if (args.type) path += `&type=${args.type}`;
1207
+ const result = await api('GET', path);
1208
+ const rows = result.data || [];
1209
+ if (!rows.length) return `No matches for "${args.query}".`;
1210
+ return rows.map(r => `[${r.type}] ${r.name}\n id: ${r.id}${r.parent_id ? '\n parent: ' + r.parent_id : ''}${r.context ? '\n ' + r.context : ''}`).join('\n\n');
1211
+ }
1212
+ },
1213
+ {
1214
+ name: 'tascan_get_worker',
1215
+ description: 'Ungated, plain read of one worker row: name, contact, org, points, streaks, timestamps. (tascan_get_worker_passport is the rich stats view; this is the boring lookup.)',
1216
+ inputSchema: {
1217
+ type: 'object',
1218
+ properties: { worker_id: { type: 'string', description: 'Worker ID' } },
1219
+ required: ['worker_id']
1220
+ },
1221
+ annotations: { title: 'Get Worker', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1222
+ handler: async (args, api) => {
1223
+ const result = await api('GET', `/workers/${args.worker_id}`);
1224
+ return JSON.stringify(result.data, null, 2);
1225
+ }
1226
+ },
1227
+ {
1228
+ name: 'tascan_find_duplicate_workers',
1229
+ description: 'Find candidate same-person worker records with per-signal match detail (Patent 4 §6.25(b) signals: phone reuse, name similarity, GPS pattern correlation). Turns identity fragmentation from an accidental discovery into a monitorable metric, and feeds the merge workflow its candidate list.',
1230
+ inputSchema: {
1231
+ type: 'object',
1232
+ properties: {
1233
+ worker_id: { type: 'string', description: 'Anchor worker to find duplicates OF (preferred — enables phone + GPS signals)' },
1234
+ name: { type: 'string', description: 'Or: search duplicates by display name' },
1235
+ threshold: { type: 'number', description: 'Min confidence 0-1 (default 0.15)' }
1236
+ },
1237
+ required: []
1238
+ },
1239
+ annotations: { title: 'Find Duplicate Workers', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1240
+ handler: async (args, api) => {
1241
+ const qs = [];
1242
+ if (args.worker_id) qs.push(`worker_id=${args.worker_id}`);
1243
+ if (args.name) qs.push(`name=${encodeURIComponent(args.name)}`);
1244
+ if (args.threshold != null) qs.push(`threshold=${args.threshold}`);
1245
+ const result = await api('GET', '/workers/duplicates?' + qs.join('&'));
1246
+ const d = result.data;
1247
+ if (!d.candidates.length) return `No duplicate candidates for ${d.anchor.name || d.anchor.id}.`;
1248
+ let text = `Duplicate candidates for ${d.anchor.name || ''} (${d.anchor.id || 'name search'}):\n`;
1249
+ d.candidates.forEach(c => {
1250
+ text += `\n${c.name} (${c.id}) — confidence ${c.confidence}\n signals: ${JSON.stringify(c.signals)}\n created ${String(c.created_at).slice(0, 10)} · last active ${String(c.last_active_at || '').slice(0, 10)} · ${c.total_points} pts`;
1251
+ });
1252
+ return text;
1253
+ }
1159
1254
  }
1160
1255
  ];
1161
1256