tascan-mcp 3.11.0 → 3.13.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.
- package/README.md +9 -0
- package/index.js +2 -1
- package/package.json +3 -2
- package/scopes.cjs +314 -0
- package/tools.cjs +96 -24
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.0 — 2026-09-14
|
|
272
|
+
- **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.
|
|
273
|
+
- **`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
|
|
274
|
+
- `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.
|
|
275
|
+
- `tascan_dispatch_to_agent` keeps the full instruction in the task description; the urgent marker no longer breaks routing.
|
|
276
|
+
|
|
277
|
+
### v3.12.0 — 2026-09-12
|
|
278
|
+
- `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.
|
|
279
|
+
|
|
271
280
|
### v3.11.0 — 2026-09-11
|
|
272
281
|
- **Signed photo URLs in every completion payload.** Photo evidence was invisible to AI agents: `photo_url` was a bare private-bucket path with no host or token. Every read path that returns a completion now also returns `photo_signed_url` (short-lived, default 1h, tunable via `TASCAN_PHOTO_URL_TTL`), `null` when there is no photo. Covered: `tascan_get_task` (task + subtask completions), `tascan_get_report` (new per-task `photos[]` when `include_responses` is true, replacing the bare `[+photo]` marker), `tascan_list_subtasks`, `tascan_query_responses`, `tascan_list_issues` / issue analysis (issue + injury photos), `tascan_zone_compliance` (PPE checkpoint photos), `tascan_condition_history` (assessment + baseline photos).
|
|
273
282
|
- One batched storage signing call per response (a 40-task list fires one request, not 40). Signing failures log and degrade to `photo_signed_url: null` with the raw path intact; the tool call never fails.
|
package/index.js
CHANGED
|
@@ -43,9 +43,10 @@ async function api(method, path, body) {
|
|
|
43
43
|
}
|
|
44
44
|
return data;
|
|
45
45
|
}
|
|
46
|
+
api.key = API_KEY; // raw-fetch tools read the key from here (S99: those functions now require auth)
|
|
46
47
|
|
|
47
48
|
const server = new Server(
|
|
48
|
-
{ name: 'tascan', version: '3.
|
|
49
|
+
{ name: 'tascan', version: '3.13.0' },
|
|
49
50
|
{ capabilities: { tools: {} } }
|
|
50
51
|
);
|
|
51
52
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tascan-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.0",
|
|
4
4
|
"mcpName": "io.github.snowbikemike/tascan-mcp",
|
|
5
|
-
"description": "TaScan MCP Server — Closed-loop autonomous operations protocol.
|
|
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
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|
|
8
8
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"index.js",
|
|
16
16
|
"tools.cjs",
|
|
17
|
+
"scopes.cjs",
|
|
17
18
|
"README.md",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|
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:
|
|
52
|
-
capabilities: ['CODE', 'SHELL', 'PLAN', '
|
|
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
|
|
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:
|
|
64
|
-
capabilities: ['RESEARCH', 'WRITE', 'REVIEW', '
|
|
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).
|
|
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
|
|
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.
|
|
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
|
|
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)
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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.',
|
|
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
|
-
|
|
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. Takes the completion_id (tascan_get_task -> completions[].id). Returns a human-readable summary (what was done, by whom, verification level, evidence hashes, AI verification result, outcome, ledger chain) plus receipt_id/serial/kid, the compact JWS, and the public verify URL. Anyone can verify the JWS offline against the JWKS or online by POSTing {jws} to the verify URL. 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: {
|
|
@@ -1079,12 +1151,12 @@ const TOOLS = [
|
|
|
1079
1151
|
required: ['to_email', 'list_id']
|
|
1080
1152
|
},
|
|
1081
1153
|
annotations: { title: 'Send Task Email', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1082
|
-
handler: async (args) => {
|
|
1083
|
-
// Call the send-task-email function directly via HTTP
|
|
1154
|
+
handler: async (args, api) => {
|
|
1155
|
+
// Call the send-task-email function directly via HTTP (S99: it now requires the caller's tsk_ key)
|
|
1084
1156
|
const url = 'https://app.tascan.io/.netlify/functions/send-task-email';
|
|
1085
1157
|
const resp = await fetch(url, {
|
|
1086
1158
|
method: 'POST',
|
|
1087
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1159
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + ((api && api.key) || API_KEY) },
|
|
1088
1160
|
body: JSON.stringify({
|
|
1089
1161
|
to_email: args.to_email,
|
|
1090
1162
|
to_name: args.to_name,
|
|
@@ -1444,10 +1516,10 @@ const TOOLS = [
|
|
|
1444
1516
|
required: ['asset_id', 'photo_url']
|
|
1445
1517
|
},
|
|
1446
1518
|
annotations: { title: 'Assess Asset Condition', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1447
|
-
handler: async (args) => {
|
|
1519
|
+
handler: async (args, api) => {
|
|
1448
1520
|
const resp = await fetch('https://app.tascan.io/api/assess-condition', {
|
|
1449
1521
|
method: 'POST',
|
|
1450
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1522
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + ((api && api.key) || API_KEY) },
|
|
1451
1523
|
body: JSON.stringify({ asset_id: args.asset_id, photo_url: args.photo_url, worker_name: args.worker_name || null, source: 'api' })
|
|
1452
1524
|
});
|
|
1453
1525
|
const data = await resp.json();
|