tascan-mcp 3.12.0 → 3.13.1

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.
package/README.md CHANGED
@@ -268,6 +268,15 @@ API keys are scoped to your organization and support rate limiting (60 requests/
268
268
 
269
269
  ## Changelog
270
270
 
271
+ ### v3.13.1 — 2026-09-14
272
+ - Docs only. `tascan_get_task` and `tascan_get_receipt` now say it plainly: a completion with status `completed` means the executor returned and a result was recorded — it does **not** mean the result was accepted. Acceptance is the receipt's `verification.result` under a named policy, and in v0.1 no policy runs for agent tasks (`no_policy_run`), so read the recorded response before treating an agent completion as success (protocol §8.3 C11). The receipt description also lists the eleven-step verifier (65,536-byte size gate, real-calendar datetimes, GPS bounds, token-free locators, `verifier.identity` vocabulary). No tool additions — still 70 tools.
273
+
274
+ ### v3.13.0 — 2026-09-14
275
+ - **Authorization scopes.** Every key and OAuth grant now carries a tier — `read`, `write`, or `full` — plus two additive permissions: `agent:dispatch` (hand PLAN/RESEARCH/WRITE/REVIEW/DEFAULT tasks to an AI agent inbox) and `agent:dispatch:code` (CODE/SHELL tasks, which run on the org's own machine). Tools are classified in `scopes.cjs`; a call outside the grant returns an `isError` result naming the scope to reconnect with. Existing OAuth connections must reconnect and tick the dispatch boxes on the consent page to dispatch again.
276
+ - **`tascan_get_receipt` (new, 70 tools).** Fetches the signed Action Receipt (Ed25519 JWS, TaScan Protocol v0.1) for one completion: what was done, by whom, evidence hashes, verification, ledger chain head — with per-field provenance. Verify offline against `https://app.tascan.io/.well-known/tascan-receipt-keys.json` or online via `POST /api/v1/receipts/verify`. Spec: https://app.tascan.io/docs/protocol/TASCAN-PROTOCOL-v0.1.md
277
+ - `tascan_get_task` reports an `agent` block (execution attempts: state, attempts, runner, trace id, error) for tasks dispatched to an agent — the only place agent failures are surfaced.
278
+ - `tascan_dispatch_to_agent` keeps the full instruction in the task description; the urgent marker no longer breaks routing.
279
+
271
280
  ### v3.12.0 — 2026-09-12
272
281
  - `tascan_send_task_email` and `tascan_assess_condition` now send the caller's API key (the TaScan functions behind them require it after the S99 security sweep). No tool additions — still 69 tools.
273
282
 
package/index.js CHANGED
@@ -46,7 +46,7 @@ async function api(method, path, body) {
46
46
  api.key = API_KEY; // raw-fetch tools read the key from here (S99: those functions now require auth)
47
47
 
48
48
  const server = new Server(
49
- { name: 'tascan', version: '3.12.0' },
49
+ { name: 'tascan', version: '3.13.1' },
50
50
  { capabilities: { tools: {} } }
51
51
  );
52
52
 
package/package.json CHANGED
@@ -1,54 +1,55 @@
1
- {
2
- "name": "tascan-mcp",
3
- "version": "3.12.0",
4
- "mcpName": "io.github.snowbikemike/tascan-mcp",
5
- "description": "TaScan MCP Server — Closed-loop autonomous operations protocol. 69 tools for projects, tasks, workers, QR codes, NFC tags, worker marketplace with consented SMS invites, geofenced zones with rules (work site / hazard + AI-verified PPE checkpoints / containment / restricted, breach alerts, zone compliance audit), AI condition assessment, worker passports, verification-gated gig payments, client invoicing from verified work (overtime / day rate / per diem / expenses), shareable report links (completion, client service report, project, evidence pack), cross-entity search, duplicate-worker detection, worker identity merge, AI issue analysis, and autonomous remediation dispatch. 15 patents filed (470+ claims). Task. Scan. Done.",
6
- "type": "module",
7
- "main": "index.js",
8
- "bin": {
9
- "tascan-mcp": "index.js"
10
- },
11
- "scripts": {
12
- "start": "node index.js"
13
- },
14
- "files": [
15
- "index.js",
16
- "tools.cjs",
17
- "README.md",
18
- "LICENSE"
19
- ],
20
- "dependencies": {
21
- "@modelcontextprotocol/sdk": "^1.27.0",
22
- "zod": "^4.3.6"
23
- },
24
- "keywords": [
25
- "tascan",
26
- "mcp",
27
- "model-context-protocol",
28
- "ai",
29
- "task-management",
30
- "qr-code",
31
- "field-operations",
32
- "workforce",
33
- "pwa",
34
- "claude"
35
- ],
36
- "author": {
37
- "name": "Michael Edward Love II",
38
- "email": "Michael@TaScan.io",
39
- "url": "https://tascan.io"
40
- },
41
- "repository": {
42
- "type": "git",
43
- "url": "git+https://github.com/snowbikemike/tascan-mcp.git"
44
- },
45
- "homepage": "https://tascan.io",
46
- "bugs": {
47
- "url": "https://github.com/snowbikemike/tascan-mcp/issues",
48
- "email": "Michael@TaScan.io"
49
- },
50
- "license": "MIT",
51
- "engines": {
52
- "node": ">=18.0.0"
53
- }
54
- }
1
+ {
2
+ "name": "tascan-mcp",
3
+ "version": "3.13.1",
4
+ "mcpName": "io.github.snowbikemike/tascan-mcp",
5
+ "description": "TaScan MCP Server — Closed-loop autonomous operations protocol. 70 tools for projects, tasks, workers, QR codes, NFC tags, worker marketplace with consented SMS invites, geofenced zones with rules (work site / hazard + AI-verified PPE checkpoints / containment / restricted, breach alerts, zone compliance audit), AI condition assessment, worker passports, verification-gated gig payments, client invoicing from verified work (overtime / day rate / per diem / expenses), shareable report links (completion, client service report, project, evidence pack), signed Action Receipts (Ed25519 JWS), cross-entity search, duplicate-worker detection, worker identity merge, AI issue analysis, and autonomous remediation dispatch. 15 patents filed (470+ claims). Task. Scan. Done.",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "bin": {
9
+ "tascan-mcp": "index.js"
10
+ },
11
+ "scripts": {
12
+ "start": "node index.js"
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "tools.cjs",
17
+ "scopes.cjs",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.27.0",
23
+ "zod": "^4.3.6"
24
+ },
25
+ "keywords": [
26
+ "tascan",
27
+ "mcp",
28
+ "model-context-protocol",
29
+ "ai",
30
+ "task-management",
31
+ "qr-code",
32
+ "field-operations",
33
+ "workforce",
34
+ "pwa",
35
+ "claude"
36
+ ],
37
+ "author": {
38
+ "name": "Michael Edward Love II",
39
+ "email": "Michael@TaScan.io",
40
+ "url": "https://tascan.io"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/snowbikemike/tascan-mcp.git"
45
+ },
46
+ "homepage": "https://tascan.io",
47
+ "bugs": {
48
+ "url": "https://github.com/snowbikemike/tascan-mcp/issues",
49
+ "email": "Michael@TaScan.io"
50
+ },
51
+ "license": "MIT",
52
+ "engines": {
53
+ "node": ">=18.0.0"
54
+ }
55
+ }
package/scopes.cjs ADDED
@@ -0,0 +1,314 @@
1
+ // ============================================================
2
+ // TaScan authorization scopes — ONE classification for MCP tools
3
+ // and REST routes (S100 W1, 2026-09-14).
4
+ // ============================================================
5
+ // Consumed by:
6
+ // live-event/netlify/functions/mcp-endpoint.js (per tool call)
7
+ // live-event/netlify/functions/api-v1.js (per route)
8
+ // live-event/netlify/functions/mcp-oauth.js (consent + token)
9
+ // live-event/netlify/functions/send-task-email.js (raw lane)
10
+ // live-event/netlify/functions/assess-condition.js (raw lane)
11
+ // live-event/admin.js mirrors the labels only.
12
+ //
13
+ // Tiers are cumulative: read < write < full.
14
+ // Two ADDITIVE dispatch permissions (never implied by a tier):
15
+ // agent:dispatch — hand PLAN/RESEARCH/WRITE/REVIEW/DEFAULT tasks to an agent inbox
16
+ // agent:dispatch:code — hand CODE/SHELL tasks to an agent inbox (touches Mike's PC);
17
+ // requires agent:dispatch as well.
18
+ //
19
+ // Storage: api_keys.scopes TEXT[] is CANONICAL (migration 150 constrains it to
20
+ // SCOPE_VALUES); oauth_grants.scope is a display mirror. Enforcement reads
21
+ // api_keys.scopes only. 'all' / '*' survive here solely for the service key
22
+ // sentinel (api-v1-helpers.js authenticateApiKey returns ['*']) and for the
23
+ // deploy window before 150 rewrites legacy rows.
24
+ //
25
+ // CommonJS, zero dependencies (bundled into Netlify functions via the same
26
+ // relative require mcp-endpoint.js already uses for tools.cjs).
27
+ // ============================================================
28
+
29
+ const TIERS = ['read', 'write', 'full'];
30
+ const RANK = { read: 1, write: 2, full: 3 };
31
+ const DISPATCH = 'agent:dispatch';
32
+ const DISPATCH_CODE = 'agent:dispatch:code';
33
+ const SCOPE_VALUES = ['read', 'write', 'full', DISPATCH, DISPATCH_CODE];
34
+ const ALL_SCOPES = SCOPE_VALUES.slice();
35
+
36
+ // Seed agent inbox (tools.cjs AGENT_REGISTRY, agent-execute, agent.js, migrations 046/137/151).
37
+ // REST additionally consults agent_registry.inbox_id (org-scoped) — see api-v1.js isAgentInbox.
38
+ const AGENT_INBOX_IDS = ['f745d0fa-421a-42e5-85be-0a3f9acd28e8'];
39
+
40
+ // ─── Task-type routing (mirrors SQL agent_task_type(title), migration 151) ─────
41
+ // Prefix-only: the title must START with TYPE: (after any leading non-letters such
42
+ // as an urgency marker). No keyword heuristics anywhere.
43
+ const TASK_TYPES = ['CODE', 'SHELL', 'MCP', 'PLAN', 'WRITE', 'RESEARCH', 'REVIEW'];
44
+ const LOCAL_TASK_TYPES = ['CODE', 'SHELL', 'PLAN', 'DEFAULT', 'MCP'];
45
+ const CLOUD_TASK_TYPES = ['RESEARCH', 'WRITE', 'REVIEW', 'DEFAULT'];
46
+ const LOCAL_ONLY_TASK_TYPES = ['CODE', 'SHELL', 'PLAN', 'MCP']; // the cloud runner refuses these; DEFAULT goes either way
47
+ const CODE_TASK_TYPES = ['CODE', 'SHELL'];
48
+ const TASK_PREFIX_RE = new RegExp('^(' + TASK_TYPES.join('|') + '):');
49
+
50
+ function taskType(title) {
51
+ const t = String(title == null ? '' : title).replace(/^[^A-Za-z]+/, '').toUpperCase();
52
+ const m = t.match(TASK_PREFIX_RE);
53
+ return m ? m[1] : 'DEFAULT';
54
+ }
55
+ // Which dispatch permission a task title needs when it lands in an agent inbox.
56
+ function dispatchScopeFor(title) {
57
+ return CODE_TASK_TYPES.includes(taskType(title)) ? DISPATCH_CODE : DISPATCH;
58
+ }
59
+ // For a batch of titles: 'code' when any is CODE/SHELL, else true (plain dispatch).
60
+ function dispatchKind(titles) {
61
+ const arr = Array.isArray(titles) ? titles : [titles];
62
+ return arr.some(t => dispatchScopeFor(t) === DISPATCH_CODE) ? 'code' : true;
63
+ }
64
+
65
+ // ─── Normalization ────────────────────────────────────────────
66
+ // api_keys.scopes (array) | oauth_grants.scope (string) | null → { tier, dispatch, code, legacy }
67
+ // 'all' or '*' anywhere → full + dispatch + code (service key sentinel / pre-150 row)
68
+ // null / undefined / [] → read, no dispatch (FAIL CLOSED)
69
+ function normalize(v) {
70
+ if (v == null) return { tier: 'read', dispatch: false, code: false, legacy: false };
71
+ const a = Array.isArray(v) ? v.map(String) : String(v).split(/[\s+,]+/).filter(Boolean);
72
+ if (a.includes('all') || a.includes('*')) return { tier: 'full', dispatch: true, code: true, legacy: true };
73
+ let tier = 'read';
74
+ for (const t of TIERS) if (a.includes(t) && RANK[t] > RANK[tier]) tier = t;
75
+ const dispatch = a.includes(DISPATCH);
76
+ // code without dispatch is not a state the consent page or key UI can produce; treat as dispatch too
77
+ const code = a.includes(DISPATCH_CODE);
78
+ return { tier, dispatch: dispatch || code, code, legacy: false };
79
+ }
80
+ function toArray(n) {
81
+ const out = TIERS.slice(0, RANK[n.tier] || 1);
82
+ if (n.dispatch || n.code) out.push(DISPATCH);
83
+ if (n.code) out.push(DISPATCH_CODE);
84
+ return out;
85
+ }
86
+ function toScopeString(n) { return toArray(n).join(' '); }
87
+
88
+ // OAuth `scope` request parameter (RFC 6749 §3.3). Absent/blank = everything requested
89
+ // (that is what a scope-less request meant before 150). Unknown tokens are dropped and reported.
90
+ // A scope string with NO known token (a vendor's fixed 'openid' / 'mcp' / account-linking scope —
91
+ // nobody has recorded what Gemini / ChatGPT / custom clients send) FAILS OPEN: it is treated exactly
92
+ // like an absent scope (`fallback: true`, unknown tokens listed for the log). Unknown tokens carry no
93
+ // security value: the consent page decides the grant regardless of what was requested, and refusing
94
+ // would only lock those clients out at deploy. `requested` is never null.
95
+ // The AS decides the grant — the consent page always offers every option; `requested` only
96
+ // picks the preselected tier.
97
+ const SCOPE_PARAM_MAX = 1024; // longer than this is not a scope list; ignore it rather than echo it
98
+ const EVERYTHING = () => ({ tier: 'full', dispatch: true, code: true, legacy: false });
99
+ function parseRequested(str) {
100
+ if (str == null || !String(str).trim()) {
101
+ return { requested: EVERYTHING(), unknown: [], absent: true, fallback: false };
102
+ }
103
+ if (String(str).length > SCOPE_PARAM_MAX) {
104
+ return { requested: EVERYTHING(), unknown: ['(scope parameter longer than ' + SCOPE_PARAM_MAX + ' chars)'], absent: false, fallback: true };
105
+ }
106
+ const toks = String(str).split(/[\s+,]+/).filter(Boolean);
107
+ const known = toks.filter(t => SCOPE_VALUES.includes(t));
108
+ const unknown = toks.filter(t => !SCOPE_VALUES.includes(t));
109
+ if (!known.length) return { requested: EVERYTHING(), unknown, absent: false, fallback: true };
110
+ return { requested: normalize(known), unknown, absent: false, fallback: false };
111
+ }
112
+ // What the consent form echoes back as its hidden `scope` field (so the POST re-parses the same request
113
+ // and the consent log records it). An over-long value is dropped rather than round-tripped.
114
+ function echoScope(str) {
115
+ if (str == null) return '';
116
+ const s = String(str);
117
+ return s.length > SCOPE_PARAM_MAX ? '' : s;
118
+ }
119
+
120
+ // Consent form → granted. Missing/invalid tier → read (fail closed). code requires dispatch.
121
+ // role: admin_users.role — only 'owner' may grant full or either dispatch permission.
122
+ // Returns { granted, clamped: [ 'full' | 'agent:dispatch' | 'agent:dispatch:code' ... ] }.
123
+ function clamp({ tier, dispatch, code }, role) {
124
+ const t = TIERS.includes(tier) ? tier : 'read';
125
+ const owner = role === 'owner';
126
+ const clamped = [];
127
+ let gt = t;
128
+ if (gt === 'full' && !owner) { gt = 'write'; clamped.push('full'); }
129
+ let gd = !!dispatch, gc = !!code && gd;
130
+ if (gd && !owner) { gd = false; gc = false; clamped.push(DISPATCH); }
131
+ else if (!!code && !gc) clamped.push(DISPATCH_CODE); // ticked code without dispatch
132
+ return { granted: { tier: gt, dispatch: gd, code: gc, legacy: false }, clamped };
133
+ }
134
+
135
+ // POST /api/v1/keys body.scopes → canonical array or null (rejects 'all', '*', junk, empty,
136
+ // and code-without-dispatch).
137
+ function validateStored(arr) {
138
+ if (!Array.isArray(arr) || !arr.length || arr.length > SCOPE_VALUES.length) return null;
139
+ if (!arr.every(s => typeof s === 'string' && SCOPE_VALUES.includes(s))) return null;
140
+ if (arr.includes(DISPATCH_CODE) && !arr.includes(DISPATCH)) return null;
141
+ return toArray(normalize(arr));
142
+ }
143
+
144
+ // granted (normalized) vs need ({ tier, dispatch?: true | 'code' | 'if_inbox' }) → null when allowed,
145
+ // else { code: 'insufficient_scope', required, granted_scope }.
146
+ // need === null → unclassified → deny (fail closed)
147
+ // dispatch 'if_inbox' is not decidable without the DB: MCP lets REST decide; REST resolves it first.
148
+ function check(granted, need) {
149
+ const g = granted || normalize(null);
150
+ const deny = required => ({ code: 'insufficient_scope', required, granted_scope: toScopeString(g) });
151
+ if (!need) return deny('unclassified');
152
+ if ((RANK[g.tier] || 0) < (RANK[need.tier] || 99)) return deny(need.tier);
153
+ if (need.dispatch === 'code' && !g.code) return deny(DISPATCH_CODE);
154
+ if (need.dispatch === true && !g.dispatch) return deny(DISPATCH);
155
+ return null;
156
+ }
157
+
158
+ // ─── Tool map: EVERY tool in tools.cjs. Unknown name → null → deny. ─────────
159
+ const R = { tier: 'read' }, W = { tier: 'write' }, F = { tier: 'full' };
160
+ const WD = { tier: 'write', dispatch: true };
161
+ // Writers into a list: dispatch decided by the target. Seed inbox is known here; other
162
+ // registered inboxes are only known to REST (agent_registry, org-scoped).
163
+ const listWrite = titles => a => {
164
+ const id = String((a && a.list_id) || '');
165
+ return { tier: 'write', dispatch: AGENT_INBOX_IDS.includes(id) ? dispatchKind(titles(a)) : 'if_inbox' };
166
+ };
167
+
168
+ const TOOL_SCOPES = {
169
+ // read (31)
170
+ tascan_list_projects: R, tascan_get_project: R, tascan_list_events: R, tascan_get_event: R,
171
+ tascan_list_agents: R, tascan_list_tasks: R, tascan_get_task: R, tascan_list_subtasks: R,
172
+ tascan_list_workers: R, tascan_list_templates: R, tascan_get_report: R, tascan_query_responses: R,
173
+ tascan_list_issues: R, tascan_get_sms_status: R, tascan_list_tags: R, tascan_get_scan_history: R,
174
+ tascan_search_marketplace: R, tascan_list_invites: R, tascan_list_zones: R, tascan_zone_compliance: R,
175
+ tascan_condition_history: R, tascan_list_assets: R, tascan_list_reports: R, tascan_list_invoices: R,
176
+ tascan_list_payments: R, tascan_get_worker_passport: R, tascan_server_info: R, tascan_find: R,
177
+ tascan_get_worker: R, tascan_find_duplicate_workers: R,
178
+ tascan_get_receipt: R, // W3 receipt.js (GET /api/v1/receipts/:id — read tier there too)
179
+ // write (27)
180
+ tascan_create_project: W, tascan_update_project: W, tascan_delete_project: W, tascan_create_event: W,
181
+ tascan_update_event: W, tascan_delete_event: W, tascan_update_task: W, tascan_reply_with_list: W,
182
+ tascan_add_subtasks: W, tascan_update_subtask: W, tascan_complete_subtask: W, tascan_delete_subtask: W,
183
+ tascan_delete_task: W, tascan_complete_task: W, tascan_create_worker: W, tascan_update_worker: W,
184
+ tascan_generate_qr: W, tascan_recommend_fix: W, tascan_register_tag: W, tascan_create_zone: W,
185
+ tascan_update_zone: W, tascan_register_asset: W, tascan_create_invoice: W, tascan_update_invoice: W,
186
+ tascan_merge_workers: W, tascan_delete_worker: W,
187
+ // write + dispatch: defining a dispatch target IS a dispatch permission
188
+ tascan_register_agent: WD,
189
+ // write + inbox rule
190
+ tascan_add_tasks: listWrite(a => (Array.isArray(a.tasks) ? a.tasks : []).map(t => t && t.title)),
191
+ tascan_apply_template: listWrite(() => []),
192
+ // dispatch: the task text decides plain vs code
193
+ tascan_dispatch_to_agent: a => ({ tier: 'write', dispatch: dispatchKind(a.task) }),
194
+ // full (7): sends to a human, spends AI credits, or pledges money
195
+ tascan_dispatch_instruction: F, tascan_auto_resolve: F, tascan_send_sms: F, tascan_send_task_email: F,
196
+ tascan_invite_worker: F, tascan_assess_condition: F, tascan_request_payment: F,
197
+ // argument-dependent (2)
198
+ tascan_analyze_issue: a => (a.server_side_ai ? { ...F } : { ...R }),
199
+ tascan_generate_report: a => (a.send_to_phone ? { ...F } : { ...W })
200
+ };
201
+ // Tools classified here that do not yet exist in tools.cjs (other workstreams' patches). Empty since
202
+ // tascan_get_receipt landed in tools.cjs (S100 phase 2b); harnesses assert TOOL_SCOPES ⊇ tools.cjs.
203
+ const PENDING_TOOLS = [];
204
+
205
+ function requiredForTool(name, args) {
206
+ const v = TOOL_SCOPES[name];
207
+ if (!v) return null;
208
+ return typeof v === 'function' ? v(args || {}) : { ...v };
209
+ }
210
+
211
+ // ─── Route map: EVERY handler name in api-v1-helpers.js parsePath × accepted methods. ─────
212
+ // (handler, method) not listed → null → 403 unclassified. 'admin' = admin-JWT routes (gate skipped).
213
+ const INBOX_WRITE = () => ({ tier: 'write', dispatch: 'if_inbox' });
214
+ const ROUTE_SCOPES = {
215
+ projects: { GET: R, POST: W },
216
+ project: { GET: R, PUT: W, DELETE: W },
217
+ project_lists: { GET: R, POST: W },
218
+ project_responses: { GET: R },
219
+ list: { GET: R, PUT: W, DELETE: W },
220
+ list_tasks: { GET: R, POST: INBOX_WRITE },
221
+ list_qr: { POST: W },
222
+ apply_template: { POST: INBOX_WRITE },
223
+ list_report: { GET: R },
224
+ task: { GET: R, PUT: W, DELETE: W }, // ANY PUT on an inbox task re-checked in handleTask (dispatch / code by existing+new title); DELETE stays write (kill switch)
225
+ task_complete: { POST: W },
226
+ task_subtasks: { GET: R, POST: W },
227
+ subtask: { GET: R, PUT: W, DELETE: W },
228
+ subtask_complete: { POST: W },
229
+ workers: { GET: R, POST: W },
230
+ worker: { GET: R, PUT: W, DELETE: W },
231
+ worker_duplicates: { GET: R },
232
+ worker_merge: { POST: W },
233
+ worker_passport: { GET: R },
234
+ agents: { GET: R, POST: WD },
235
+ zones: { GET: R, POST: W },
236
+ zone: { GET: R, PUT: W, DELETE: W },
237
+ zone_events: { GET: R },
238
+ zone_compliance: { GET: R },
239
+ assets: { GET: R, POST: W },
240
+ asset: { GET: R },
241
+ payments: { GET: R, POST: F },
242
+ invoices: { GET: R, POST: W },
243
+ invoice: { GET: R, PUT: W, PATCH: W, DELETE: W },
244
+ reports: { GET: R, POST: b => (b && b.send_to_phone ? { ...F } : { ...W }) },
245
+ meta: { GET: R },
246
+ find: { GET: R },
247
+ scans: { GET: R },
248
+ sms: { POST: F },
249
+ sms_status: { GET: R },
250
+ marketplace: { GET: R },
251
+ marketplace_invite: { POST: F },
252
+ marketplace_invites: { GET: R },
253
+ templates: { GET: R },
254
+ issue: { GET: R, PUT: W },
255
+ list_issues: { GET: R },
256
+ issue_recommend: { POST: W },
257
+ issue_dispatch: { POST: F },
258
+ issue_analyze: { POST: F },
259
+ issue_auto_resolve: { POST: F },
260
+ analytics_platform: { GET: R },
261
+ analytics_org: { GET: R },
262
+ analytics_resolutions: { GET: R },
263
+ tags: { GET: R, POST: W },
264
+ tag: { GET: R, PUT: W, DELETE: W },
265
+ tag_scans: { GET: R },
266
+ keys: 'admin', key: 'admin'
267
+ };
268
+
269
+ function requiredForRoute(handler, method, body) {
270
+ const h = ROUTE_SCOPES[handler];
271
+ if (!h || h === 'admin') return null;
272
+ const v = h[method];
273
+ if (!v) return null;
274
+ return typeof v === 'function' ? v(body || {}) : { ...v };
275
+ }
276
+
277
+ // ─── Error formatters ─────────────────────────────────────────
278
+ const SCOPE_LABEL = {
279
+ read: 'the "read" access level',
280
+ write: 'the "write" access level',
281
+ full: 'the "full" access level',
282
+ [DISPATCH]: 'the "agent:dispatch" permission ("Let this app hand tasks to my AI agents")',
283
+ [DISPATCH_CODE]: 'the "agent:dispatch:code" permission ("...including code and shell tasks on my computer")',
284
+ unclassified: 'a classification (this tool is not in the scope map)'
285
+ };
286
+ function reconnectHint(required) {
287
+ let s = 'To fix: disconnect TaScan in your MCP client and connect it again — on the TaScan sign-in screen pick a higher access level';
288
+ if (required === DISPATCH) s += ' and tick "Let this app hand tasks to my AI agents"';
289
+ if (required === DISPATCH_CODE) s += ' and tick both agent checkboxes ("...including code and shell tasks on my computer")';
290
+ s += '. If you connect with a tsk_ API key instead, generate a new key with that access at app.tascan.io → Team → API Keys (owner only).';
291
+ return s;
292
+ }
293
+ function mcpDenialText(toolName, d) {
294
+ return `insufficient_scope: ${toolName} needs ${SCOPE_LABEL[d.required] || d.required}; this connection was granted "${d.granted_scope}".\n\n${reconnectHint(d.required)}`;
295
+ }
296
+ function restDenialBody(d) {
297
+ return {
298
+ error: 'insufficient_scope',
299
+ required_scope: d.required,
300
+ granted_scope: d.granted_scope,
301
+ error_description: `This credential was granted "${d.granted_scope}"; this request requires "${d.required}". Generate a key with more access at app.tascan.io → Team → API Keys, or reconnect your MCP client and choose a higher access level${d.required.startsWith('agent:') ? ' and the agent-dispatch checkbox' : ''}.`
302
+ };
303
+ }
304
+ function restDenialHeaders(d) {
305
+ return { 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${d.required}"` };
306
+ }
307
+
308
+ module.exports = {
309
+ SCOPE_VALUES, ALL_SCOPES, TIERS, RANK, DISPATCH, DISPATCH_CODE, AGENT_INBOX_IDS,
310
+ TASK_TYPES, LOCAL_TASK_TYPES, CLOUD_TASK_TYPES, LOCAL_ONLY_TASK_TYPES, CODE_TASK_TYPES, taskType, dispatchScopeFor, dispatchKind,
311
+ normalize, toArray, toScopeString, parseRequested, echoScope, SCOPE_PARAM_MAX, clamp, validateStored, check,
312
+ TOOL_SCOPES, PENDING_TOOLS, ROUTE_SCOPES, requiredForTool, requiredForRoute,
313
+ mcpDenialText, restDenialBody, restDenialHeaders
314
+ };
package/tools.cjs CHANGED
@@ -41,6 +41,13 @@ function fmtResponse(responseValue, notes) {
41
41
  return { value: value == null || value === '' ? null : String(value), note };
42
42
  }
43
43
 
44
+ // Authorization scopes + task-type routing (S100 W1). ONE routing rule: scopes.cjs taskType() mirrors
45
+ // SQL agent_task_type() (migration 151) — prefix-only, no keyword heuristics.
46
+ const { taskType, LOCAL_ONLY_TASK_TYPES, AGENT_INBOX_IDS } = require('./scopes.cjs');
47
+
48
+ // Capabilities advertised here MUST match what each runner actually accepts (migration 151 seeds the same
49
+ // rows): local = CODE/SHELL/PLAN/DEFAULT/MCP; cloud = RESEARCH/WRITE/REVIEW/DEFAULT (text only — it
50
+ // refuses CODE/SHELL/PLAN/MCP). DEFAULT goes local while the PC agent heartbeats, else cloud.
44
51
  const AGENT_REGISTRY = [
45
52
  {
46
53
  id: 'claude-code-local',
@@ -48,11 +55,11 @@ const AGENT_REGISTRY = [
48
55
  type: 'local',
49
56
  model: 'claude-opus-4-6',
50
57
  worker_id: '9d194a8e-0c38-4cba-ba62-8560e497a0c3',
51
- inbox_id: 'f745d0fa-421a-42e5-85be-0a3f9acd28e8',
52
- capabilities: ['CODE', 'SHELL', 'PLAN', 'WRITE', 'RESEARCH', 'DEFAULT'],
58
+ inbox_id: AGENT_INBOX_IDS[0],
59
+ capabilities: ['CODE', 'SHELL', 'PLAN', 'DEFAULT', 'MCP'],
53
60
  location: 'Las Vegas, NV (Mike\'s PC)',
54
61
  status: 'active',
55
- description: 'Local Claude Code agent on Mike\'s PC. Spawns claude -p for code/shell tasks. Full filesystem + tool access. Picks up tasks from AI Inbox via Supabase Realtime.'
62
+ description: 'Local Claude Code agent on Mike\'s PC. Spawns an isolated claude -p (own settings, no desktop-control tools) for CODE/SHELL/PLAN/MCP tasks. Picks up tasks from the AI Inbox via Supabase Realtime. Dispatching here needs agent:dispatch (CODE/SHELL: agent:dispatch:code).'
56
63
  },
57
64
  {
58
65
  id: 'cloud-agent',
@@ -60,11 +67,11 @@ const AGENT_REGISTRY = [
60
67
  type: 'cloud',
61
68
  model: 'claude-haiku-4-5-20251001',
62
69
  worker_id: '9d194a8e-0c38-4cba-ba62-8560e497a0c3',
63
- inbox_id: 'f745d0fa-421a-42e5-85be-0a3f9acd28e8',
64
- capabilities: ['RESEARCH', 'WRITE', 'REVIEW', 'MCP', 'DEFAULT'],
70
+ inbox_id: AGENT_INBOX_IDS[0],
71
+ capabilities: ['RESEARCH', 'WRITE', 'REVIEW', 'DEFAULT'],
65
72
  location: 'Netlify (cloud)',
66
73
  status: 'active',
67
- description: 'Cloud agent via Netlify function (agent-execute.js). Uses Anthropic API for RESEARCH/WRITE tasks. Triggered by Supabase pg_net webhook on task insert.'
74
+ description: 'Cloud agent via Netlify background function (agent-execute-background.js). Text only: RESEARCH/WRITE/REVIEW (and DEFAULT when the local agent is offline). Refuses CODE/SHELL/PLAN/MCP — those run on the local agent.'
68
75
  }
69
76
  ];
70
77
  // Dynamic registry entries (from tascan_register_agent calls within this session)
@@ -252,7 +259,7 @@ const TOOLS = [
252
259
  },
253
260
  {
254
261
  name: 'tascan_add_tasks',
255
- description: 'Add one or more tasks to an event (task list). Supports bulk creation. IMPORTANT: Set response_type correctly — use "text" for info collection (names, phones, emails, notes), "photo" for visual verification (inspections, serial numbers, damage checks), "checkbox" only for simple confirmations. NOTE: To dispatch tasks to the Claude Code agent running on Mike\'s PC, use tascan_dispatch_to_agent instead — it routes directly to the agent\'s inbox with zero configuration needed.',
262
+ description: 'Add one or more tasks to an event (task list). Supports bulk creation. IMPORTANT: Set response_type correctly — use "text" for info collection (names, phones, emails, notes), "photo" for visual verification (inspections, serial numbers, damage checks), "checkbox" only for simple confirmations. NOTE: To dispatch tasks to an AI agent use tascan_dispatch_to_agent instead. Writing into an agent inbox list requires the agent:dispatch permission (agent:dispatch:code for CODE:/SHELL: titles) — without it the call is refused.',
256
263
  inputSchema: {
257
264
  type: 'object',
258
265
  properties: {
@@ -301,13 +308,13 @@ const TOOLS = [
301
308
  },
302
309
  {
303
310
  name: 'tascan_dispatch_to_agent',
304
- description: 'PREFERRED tool for sending work to an AI agent. Dispatches a task to the agent\'s inbox — picked up and executed automatically. No list ID needed. Supports prefixes: CODE: SHELL: RESEARCH: WRITE: PLAN: for routing. Use "agent" param to target a specific agent (default: claude-code-local). Use tascan_list_agents to discover available agents.',
311
+ description: 'PREFERRED tool for sending work to an AI agent. Dispatches a task to the agent\'s inbox — picked up and executed automatically. No list ID needed. REQUIRES the agent:dispatch permission on this connection (CODE:/SHELL: tasks also require agent:dispatch:code) — reconnect and tick the agent checkbox(es) if refused. Routing is by TITLE PREFIX only: CODE: SHELL: PLAN: MCP: → local Claude Code on Mike\'s PC; RESEARCH: WRITE: REVIEW: → cloud; no prefix (DEFAULT) → local while the PC agent is alive, else cloud. The cloud agent refuses CODE/SHELL/PLAN/MCP. Use "agent" param to target a specific agent (default: claude-code-local). Use tascan_list_agents to discover available agents; track progress with tascan_get_task (its "agent" block).',
305
312
  inputSchema: {
306
313
  type: 'object',
307
314
  properties: {
308
- task: { type: 'string', description: 'The task description. Prefix with CODE: SHELL: RESEARCH: WRITE: PLAN: for routing, or just plain text.' },
309
- agent: { type: 'string', description: 'Agent ID or name to dispatch to (default: claude-code-local). Use tascan_list_agents to see options.' },
310
- priority: { type: 'string', enum: ['normal', 'urgent'], description: 'Priority level (default: normal)' }
315
+ task: { type: 'string', description: 'The full task text. START it with CODE: SHELL: PLAN: MCP: RESEARCH: WRITE: or REVIEW: for routing (prefix-only — nothing may precede it), or leave it unprefixed (DEFAULT). The whole text is stored in the task description; the first 140 chars become the title.' },
316
+ agent: { type: 'string', description: 'Agent ID or name to dispatch to (default: claude-code-local). Use tascan_list_agents to see options. An unknown agent is an error, never a silent fallback.' },
317
+ priority: { type: 'string', enum: ['normal', 'urgent'], description: 'Priority level (default: normal). The urgent marker is placed AFTER the routing prefix so it never breaks routing.' }
311
318
  },
312
319
  required: ['task']
313
320
  },
@@ -317,10 +324,28 @@ const TOOLS = [
317
324
  try { registered = (await api('GET', '/agents')).data || []; } catch (e) { /* registry unreachable — fall back to seeds */ }
318
325
  const allAgents = [...AGENT_REGISTRY, ...dynamicAgents, ...registered.filter(r => !AGENT_REGISTRY.some(s => s.id === r.id))];
319
326
  const agentQuery = (args.agent || 'claude-code-local').toLowerCase();
320
- const agent = allAgents.find(a => a.id === agentQuery || a.name.toLowerCase() === agentQuery) || allAgents[0];
321
- const title = args.priority === 'urgent' ? `🔴 ${args.task}` : args.task;
322
- const result = await api('POST', `/lists/${agent.inbox_id}/tasks`, [{ title, response_type: 'text' }]);
323
- return `Task dispatched to ${agent.name}!\n\nAgent: ${agent.name} (${agent.id})\nType: ${agent.type}\nLocation: ${agent.location}\nTask: ${title}\nStatus: Queued — agent will pick this up automatically.\n\n${JSON.stringify(result.data, null, 2)}`;
327
+ const agent = allAgents.find(a => a.id === agentQuery || a.name.toLowerCase() === agentQuery);
328
+ // A typo must never silently land work on Mike's machine (the old `|| allAgents[0]` did exactly that).
329
+ if (!agent) throw new Error(`Unknown agent "${args.agent}". Available: ${allAgents.map(a => a.id).join(', ')}`);
330
+ const text = String(args.task == null ? '' : args.task).trim();
331
+ if (!text) throw new Error('task is required');
332
+ const type = taskType(text);
333
+ if (agent.type === 'cloud' && LOCAL_ONLY_TASK_TYPES.includes(type)) {
334
+ return `Refused: the cloud agent cannot run ${type} tasks (text only: RESEARCH/WRITE/REVIEW/DEFAULT). Dispatch to claude-code-local (or omit "agent"). Nothing was queued.`;
335
+ }
336
+ // Title = first 140 chars with the TYPE: prefix intact (runners and the scope gate route on the title
337
+ // prefix); the FULL text lives in description so nothing past 140 chars is lost. The urgent marker
338
+ // goes AFTER the prefix — `🔴 CODE: …` used to defeat startsWith('CODE:') and fall to DEFAULT.
339
+ let title = text;
340
+ if (args.priority === 'urgent') {
341
+ const m = title.match(/^([A-Za-z]+:)\s*/);
342
+ title = m ? `${m[1]} 🔴 ${title.slice(m[0].length)}` : `🔴 ${title}`;
343
+ }
344
+ title = title.slice(0, 140);
345
+ const result = await api('POST', `/lists/${agent.inbox_id}/tasks`, [{ title, description: text, response_type: 'text' }]);
346
+ const created = (result.data && result.data[0]) || {};
347
+ const route = LOCAL_ONLY_TASK_TYPES.includes(type) ? 'local (Claude Code on Mike\'s PC)' : type === 'DEFAULT' ? 'local when the PC agent is alive, else cloud' : 'cloud (text only)';
348
+ return `Task dispatched (task_id: ${created.id || 'unknown'}) to ${agent.name} (${agent.id}).\nType: ${type} → route: ${route}\nTitle: ${title}\nStatus: queued. Track it with tascan_get_task(task_id): the "agent" block goes queued → claimed → running → completed | failed; a run that dies is recorded as expired/failed and retried up to 3×.\n\n${JSON.stringify(result.data, null, 2)}`;
324
349
  }
325
350
  },
326
351
  {
@@ -344,7 +369,7 @@ const TOOLS = [
344
369
  },
345
370
  {
346
371
  name: 'tascan_register_agent',
347
- description: 'Register a new AI agent in the agent registry. The agent will appear in tascan_list_agents and can receive dispatched tasks. Self-registration for AI agents joining the TaScan network.',
372
+ description: 'Register a new AI agent in the agent registry. The agent will appear in tascan_list_agents and can receive dispatched tasks. Self-registration for AI agents joining the TaScan network. REQUIRES the agent:dispatch permission (defining a dispatch target is a dispatch permission); inbox_id must be a task list (event) in your organization.',
348
373
  inputSchema: {
349
374
  type: 'object',
350
375
  properties: {
@@ -395,7 +420,7 @@ const TOOLS = [
395
420
  },
396
421
  {
397
422
  name: 'tascan_get_task',
398
- description: 'Get details of a specific task including completions and subtasks. Each completion carries photo_url (raw storage path, stable) and photo_signed_url (short-lived fetchable URL, ~1h; null when no photo) so you can actually view the photo evidence.',
423
+ description: 'Get details of a specific task including completions and subtasks. Each completion carries photo_url (raw storage path, stable) and photo_signed_url (short-lived fetchable URL, ~1h; null when no photo) so you can actually view the photo evidence. Tasks dispatched to an AI agent also carry an "agent" block (state claimed|running|completed|failed|expired|released, attempts, current run with runner/trace_id/error) — the only place agent failures are reported. A completion with status "completed" means the executor returned and its result was recorded (a model refusal, a wrong answer or an administrative note all "complete"); it does NOT mean the requested result was accepted. Acceptance is the completion evidence_check / the receipt verification.result under a named policy, and in v0.1 no policy exists for agent tasks (exact-output and rubric policies are v0.2) — check the recorded response text yourself before treating an agent completion as success (protocol §2.6, §3.2, §8.3 C11).',
399
424
  inputSchema: {
400
425
  type: 'object',
401
426
  properties: { task_id: { type: 'string', description: 'Task ID' } },
@@ -404,12 +429,59 @@ const TOOLS = [
404
429
  annotations: { title: 'Get Task', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
405
430
  handler: async (args, api) => {
406
431
  const result = await api('GET', `/tasks/${args.task_id}`);
407
- return JSON.stringify(result.data, null, 2);
432
+ const d = result.data || {};
433
+ let text = '';
434
+ if (d.agent) {
435
+ const a = d.agent, c = a.current || {};
436
+ text += `AGENT: state=${a.state} | failed/expired attempts=${a.attempts}/${a.max_attempts}` +
437
+ (c.runner ? ` | runner=${c.runner}` : '') + (c.trace_id ? ` | trace_id=${c.trace_id}` : '') +
438
+ (c.error ? `\nLast error: ${c.error}` : '') + (c.outcome ? `\nOutcome: ${String(c.outcome).slice(0, 300)}` : '') + '\n\n';
439
+ }
440
+ return text + JSON.stringify(d, null, 2);
441
+ }
442
+ },
443
+ {
444
+ name: 'tascan_get_receipt',
445
+ description: 'Fetch the signed Action Receipt (Ed25519 JWS) for one completed task by completion_id (tascan_get_task -> completions[].id). Returns a readable summary (what, who, verification, evidence hashes, outcome, ledger chain) plus receipt_id/serial/kid, the compact JWS and the public verify URL. Verify offline against the JWKS or online by POSTing a JSON body whose jws field holds the compact receipt. Read outcome and verification separately: outcome completed = the executor returned and a result was recorded; verification.result = the verdict of a named policy (policy_id/version/hash + verifier non-null together); all-null verification with reason no_policy_run = no policy ran, true of every agent task in v0.1. Never treat outcome=completed as success without a policy verdict you trust (protocol 8.3 C11). Verifier = eleven steps (6.8): JWS over 65,536 bytes = too_large; real datetimes; GPS inside the globe; locators never carry a list token; verifier.identity is ai:, admin:, customer: or system: prefixed. Read tier.',
446
+ inputSchema: {
447
+ type: 'object',
448
+ properties: { completion_id: { type: 'string', description: 'task_completions.id (UUID) - from tascan_get_task -> completions[].id' } },
449
+ required: ['completion_id']
450
+ },
451
+ annotations: { title: 'Get Action Receipt', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
452
+ handler: async (args, api) => {
453
+ const id = String(args.completion_id || '').trim().toLowerCase();
454
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) throw new Error('completion_id must be a UUID');
455
+ const env = await api('GET', `/receipts/${id}`); // receipt.js returns the envelope itself (no data wrapper)
456
+ const r = (env.claims && env.claims.receipt) || {};
457
+ const v = x => (x && typeof x === 'object' && 'value' in x) ? x.value : x;
458
+ const a = r.action || {}, ex = r.executor || {}, ts = r.timestamps || {}, ver = r.verification || null, ch = r.chain || {};
459
+ const vr = ver ? v(ver.result) : null, vf = ver ? v(ver.verifier) : null; // null Claims stay null (verification block is always present)
460
+ const verify = env.verify || {};
461
+ const lines = [
462
+ '=== TASCAN ACTION RECEIPT ===',
463
+ `Receipt: ${env.receipt_id} (serial ${env.serial}, kid ${env.kid}, status ${env.status}, view ${env.view})`,
464
+ `Subject: ${env.subject} (the Evidence row; the Action is task ${v(a.task_id)})`,
465
+ `Action: ${(env.private && env.private.title) || '(title signed as sha256 ' + String(v(a.title_sha256) || '').slice(0, 16) + '…)'} [task ${v(a.task_id)} in list ${(env.private && env.private.list_name) || v(a.list_id)}]`,
466
+ `Executor: ${(env.private && env.private.executor_display) || v(ex.id) || 'unknown'} (${v(ex.kind)}, verification_level ${v(ex.verification_level)}${v(ex.runner) ? `, runner ${v(ex.runner)}, trace ${v(ex.trace_id)}, attempt ${v(ex.attempt)}` : ''})`,
467
+ `Outcome: ${v(r.outcome)} | completed ${v(ts.completed)} | recorded ${v(ts.recorded)}`,
468
+ `Evidence: ${(r.evidence || []).map(e => `${v(e.type)} sha256:${String(v(e.sha256) || '').slice(0, 16)}...`).join(', ') || 'none'}`,
469
+ `Verification: ${vr ? `${vr.verified ? 'VERIFIED' : 'NOT verified'} (confidence ${vr.confidence}) by ${vf && vf.kind}${vf && vf.identity ? ' ' + vf.identity : ''}, policy ${v(ver.policy_id)}@${v(ver.policy_version)}${env.private && env.private.verification_reason ? ' - ' + env.private.verification_reason + ' (unsigned; sha256 signed)' : ''}` : 'none recorded'}`,
470
+ (() => { const t = v(a.target); return t ? `Target: ${(env.private && env.private.target_label) || '(label signed as sha256)'}${env.private && env.private.target_hardware_id ? ' hw ' + env.private.target_hardware_id : ''} [${t.type} ${t.id}, ${t.correlated_by}] (label + hardware id signed as sha256 only)` : null; })(),
471
+ `Ledger: ${v(ch.event_count)} events, head ${String(v(ch.ledger_head_hash) || '').slice(0, 16)}..., row matches signed snapshot: ${v(ch.row_matches_snapshot)}${(v(ch.mutated_fields) || []).length ? ' (mutated: ' + v(ch.mutated_fields).join(', ') + ')' : ''}`,
472
+ env.private && env.private.notes ? `Notes (unsigned; sha256 is in evidence): ${env.private.notes}` : null,
473
+ '',
474
+ `Verify online: POST ${verify.url} body {"jws": "<JWS below>"}`,
475
+ `JWKS: ${verify.jwks} Schema: ${verify.schema} Signed at: ${env.signed_at}`,
476
+ '',
477
+ 'JWS:', env.jws
478
+ ].filter(x => x !== null);
479
+ return lines.join('\n');
408
480
  }
409
481
  },
410
482
  {
411
483
  name: 'tascan_update_task',
412
- description: 'Update a task (title, description, response_type, flags, sort_order)',
484
+ description: 'Update a task (title, description, response_type, flags, sort_order). A task that sits in an AI agent inbox is agent input (the runner executes title + description), so ANY edit to it needs the agent:dispatch permission — agent:dispatch:code when the task is or becomes CODE:/SHELL:.',
413
485
  inputSchema: {
414
486
  type: 'object',
415
487
  properties: {