tascan-mcp 3.16.1 → 3.17.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 +6 -0
- package/index.js +1 -1
- package/package.json +2 -1
- package/scopes.cjs +52 -9
- package/tools-fanout.cjs +175 -0
- package/tools.cjs +76 -1
package/README.md
CHANGED
|
@@ -288,6 +288,12 @@ API keys are scoped to your organization and support rate limiting (60 requests/
|
|
|
288
288
|
|
|
289
289
|
## Changelog
|
|
290
290
|
|
|
291
|
+
### v3.17.0 — 2026-09-28
|
|
292
|
+
- **Fan-out (phase 1b), 101 tools.** Three new tools over the phase 1a REST surface (`/coord/fanouts`, migration 224): `tascan_create_fanout` (1-30 RESEARCH seats plus one synthesizer under ONE cycle root T_F — zero Decision cards, zero Parked cards, zero pages per seat; requires `agent:dispatch:code`; fails closed `fanout_ceiling_unset` until the org owner sets a spending ceiling), `tascan_get_fanout` (`view=status|report|rollup|verify`, read tier), `tascan_control_fanout` (`pause|resume|cancel|close_barrier` — no `amend`, no `answer` in phase 1; requires `agent:dispatch:code`). `dry_run` on create is forwarded to the route as-is; the route has no server-side preflight for this path yet, so it does not yet prevent a real create — see the tool description.
|
|
293
|
+
|
|
294
|
+
### v3.16.2 — 2026-09-28
|
|
295
|
+
- 98 tools. Rolls up 3.16.0 to 3.16.2: `tascan_get_usage` (GET /usage, per-key daily actions and rate-limit headers, migration 220); the device control plane read/revoke tools (`tascan_list_devices`, `tascan_get_device`, `tascan_revoke_device`; register and rotate stay admin-app only by design); `tascan_delegate_to_agent` (child keys with a TTL of 60 s to 24 h clamped to the parent, non-delegable flags refused, the plaintext returned once) and the per-key action budgets that fail closed (migration 221). Hosted MCP at https://app.tascan.io/mcp already serves this version; this release brings the stdio package up to it.
|
|
296
|
+
|
|
291
297
|
### v3.15.0 — 2026-09-19
|
|
292
298
|
|
|
293
299
|
- `tascan_create_cycle` gains an optional `reviews[]` array — a multi-lens review panel (design item 14a): 1–8 lenses, each `{lens, brief, provider: openai|anthropic|gemini, model?, blocking? (default true), max_tool_calls? (openai only)}`, forwarded to `POST /coord/cycles`; at least one lens must be blocking. Omit it for the single OpenAI review.
|
package/index.js
CHANGED
|
@@ -53,7 +53,7 @@ async function api(method, path, body, idemKey) {
|
|
|
53
53
|
api.key = API_KEY; // raw-fetch tools read the key from here (S99: those functions now require auth)
|
|
54
54
|
|
|
55
55
|
const server = new Server(
|
|
56
|
-
{ name: 'tascan', version: '3.16.
|
|
56
|
+
{ name: 'tascan', version: '3.16.2' },
|
|
57
57
|
{ capabilities: { tools: {} } }
|
|
58
58
|
);
|
|
59
59
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tascan-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.17.0",
|
|
4
4
|
"mcpName": "io.github.snowbikemike/tascan-mcp",
|
|
5
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",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"index.js",
|
|
16
16
|
"tools.cjs",
|
|
17
17
|
"tools-devices.cjs",
|
|
18
|
+
"tools-fanout.cjs",
|
|
18
19
|
"scopes.cjs",
|
|
19
20
|
"README.md",
|
|
20
21
|
"LICENSE"
|
package/scopes.cjs
CHANGED
|
@@ -34,7 +34,13 @@ const DISPATCH_CODE = 'agent:dispatch:code';
|
|
|
34
34
|
// requires full, and unreachable through OAuth (mcp-oauth.js's consent flow passes no device flag, so clamp()
|
|
35
35
|
// below never grants it to a connector token even though clamp is capable of granting it to an owner).
|
|
36
36
|
const DEVICE_ADMIN = 'device:admin';
|
|
37
|
-
|
|
37
|
+
// B2 (migration 221): delegate — a FOURTH additive permission, never implied by a tier — owner-only, requires
|
|
38
|
+
// full (like device:admin), and unreachable through OAuth (mcp-oauth.js's consent flow passes no delegate flag,
|
|
39
|
+
// so clamp() below never grants it to a connector token even though clamp is capable of granting it to an owner).
|
|
40
|
+
// A key holding it may mint time-limited, narrower child keys (coord_delegate_key) — read/write only, never a
|
|
41
|
+
// wider or lateral grant (I-6).
|
|
42
|
+
const DELEGATE = 'delegate';
|
|
43
|
+
const SCOPE_VALUES = ['read', 'write', 'full', DISPATCH, DISPATCH_CODE, DEVICE_ADMIN, DELEGATE];
|
|
38
44
|
const ALL_SCOPES = SCOPE_VALUES.slice();
|
|
39
45
|
|
|
40
46
|
// Seed agent inbox (tools.cjs AGENT_REGISTRY, agent-execute, agent.js, migrations 046/137/151).
|
|
@@ -74,23 +80,25 @@ function dispatchKind(titles) {
|
|
|
74
80
|
// (P0 / migration 216): a legacy row minted before this permission existed must not silently gain it.
|
|
75
81
|
// null / undefined / [] → read, no dispatch, no device_admin (FAIL CLOSED)
|
|
76
82
|
function normalize(v) {
|
|
77
|
-
if (v == null) return { tier: 'read', dispatch: false, code: false, device_admin: false, legacy: false };
|
|
83
|
+
if (v == null) return { tier: 'read', dispatch: false, code: false, device_admin: false, delegate: false, legacy: false };
|
|
78
84
|
const a = Array.isArray(v) ? v.map(String) : String(v).split(/[\s+,]+/).filter(Boolean);
|
|
79
|
-
if (a.includes('*')) return { tier: 'full', dispatch: true, code: true, device_admin: true, legacy: true };
|
|
80
|
-
if (a.includes('all')) return { tier: 'full', dispatch: true, code: true, device_admin: false, legacy: true };
|
|
85
|
+
if (a.includes('*')) return { tier: 'full', dispatch: true, code: true, device_admin: true, delegate: true, legacy: true };
|
|
86
|
+
if (a.includes('all')) return { tier: 'full', dispatch: true, code: true, device_admin: false, delegate: false, legacy: true };
|
|
81
87
|
let tier = 'read';
|
|
82
88
|
for (const t of TIERS) if (a.includes(t) && RANK[t] > RANK[tier]) tier = t;
|
|
83
89
|
const dispatch = a.includes(DISPATCH);
|
|
84
90
|
// code without dispatch is not a state the consent page or key UI can produce; treat as dispatch too
|
|
85
91
|
const code = a.includes(DISPATCH_CODE);
|
|
86
92
|
const deviceAdmin = a.includes(DEVICE_ADMIN);
|
|
87
|
-
|
|
93
|
+
const delegate = a.includes(DELEGATE);
|
|
94
|
+
return { tier, dispatch: dispatch || code, code, device_admin: deviceAdmin, delegate, legacy: false };
|
|
88
95
|
}
|
|
89
96
|
function toArray(n) {
|
|
90
97
|
const out = TIERS.slice(0, RANK[n.tier] || 1);
|
|
91
98
|
if (n.dispatch || n.code) out.push(DISPATCH);
|
|
92
99
|
if (n.code) out.push(DISPATCH_CODE);
|
|
93
100
|
if (n.device_admin) out.push(DEVICE_ADMIN);
|
|
101
|
+
if (n.delegate) out.push(DELEGATE);
|
|
94
102
|
return out;
|
|
95
103
|
}
|
|
96
104
|
function toScopeString(n) { return toArray(n).join(' '); }
|
|
@@ -133,7 +141,7 @@ function echoScope(str) {
|
|
|
133
141
|
// P0 (migration 216): device_admin is accepted here for completeness and unit-tested (a non-owner clamp drops
|
|
134
142
|
// it), but the ONLY real caller — mcp-oauth.js's OAuth consent flow — never passes a device flag in `pick` at
|
|
135
143
|
// all, so a connector token can never hold device:admin regardless of the granting admin's role.
|
|
136
|
-
function clamp({ tier, dispatch, code, device_admin }, role) {
|
|
144
|
+
function clamp({ tier, dispatch, code, device_admin, delegate }, role) {
|
|
137
145
|
const t = TIERS.includes(tier) ? tier : 'read';
|
|
138
146
|
const owner = role === 'owner';
|
|
139
147
|
const clamped = [];
|
|
@@ -144,7 +152,11 @@ function clamp({ tier, dispatch, code, device_admin }, role) {
|
|
|
144
152
|
else if (!!code && !gc) clamped.push(DISPATCH_CODE); // ticked code without dispatch
|
|
145
153
|
let gda = !!device_admin && owner;
|
|
146
154
|
if (!!device_admin && !gda) clamped.push(DEVICE_ADMIN);
|
|
147
|
-
|
|
155
|
+
// B2 (migration 221): delegate is owner-only AND requires the final (already-clamped) tier to be full — the
|
|
156
|
+
// same "only an owner-held, full-tier key may ever mint a delegation" rule 221's own CHECK enforces in storage.
|
|
157
|
+
let gdel = !!delegate && owner && gt === 'full';
|
|
158
|
+
if (!!delegate && !gdel) clamped.push(DELEGATE);
|
|
159
|
+
return { granted: { tier: gt, dispatch: gd, code: gc, device_admin: gda, delegate: gdel, legacy: false }, clamped };
|
|
148
160
|
}
|
|
149
161
|
|
|
150
162
|
// POST /api/v1/keys body.scopes → canonical array or null (rejects 'all', '*', junk, empty,
|
|
@@ -154,6 +166,7 @@ function validateStored(arr) {
|
|
|
154
166
|
if (!arr.every(s => typeof s === 'string' && SCOPE_VALUES.includes(s))) return null;
|
|
155
167
|
if (arr.includes(DISPATCH_CODE) && !arr.includes(DISPATCH)) return null;
|
|
156
168
|
if (arr.includes(DEVICE_ADMIN) && !arr.includes('full')) return null;
|
|
169
|
+
if (arr.includes(DELEGATE) && !arr.includes('full')) return null;
|
|
157
170
|
return toArray(normalize(arr));
|
|
158
171
|
}
|
|
159
172
|
|
|
@@ -171,6 +184,7 @@ function check(granted, need) {
|
|
|
171
184
|
if (need.human_only) return { code: 'human_session_required', required: 'human_session_required', granted_scope: toScopeString(g) };
|
|
172
185
|
if ((RANK[g.tier] || 0) < (RANK[need.tier] || 99)) return deny(need.tier);
|
|
173
186
|
if (need.device_admin && !g.device_admin) return deny(DEVICE_ADMIN);
|
|
187
|
+
if (need.delegate && !g.delegate) return deny(DELEGATE);
|
|
174
188
|
if (need.dispatch === 'code' && !g.code) return deny(DISPATCH_CODE);
|
|
175
189
|
if (need.dispatch === true && !g.dispatch) return deny(DISPATCH);
|
|
176
190
|
return null;
|
|
@@ -205,6 +219,11 @@ const TOOL_SCOPES = {
|
|
|
205
219
|
tascan_post_message: W,
|
|
206
220
|
// creating a cycle queues a CODE:/SHELL: prompt on the AI Inbox → agent:dispatch:code, always
|
|
207
221
|
tascan_create_cycle: { tier: 'write', dispatch: 'code' },
|
|
222
|
+
// Fan-out phase 1b (2026-09-28): three tools over coord-fanout-lib.js's REST routes — same shapes as
|
|
223
|
+
// ROUTE_SCOPES' coord_fanouts / coord_fanout / coord_fanout_control below (:363-365).
|
|
224
|
+
tascan_create_fanout: { tier: 'write', dispatch: 'code' },
|
|
225
|
+
tascan_get_fanout: R,
|
|
226
|
+
tascan_control_fanout: { tier: 'write', dispatch: 'code' },
|
|
208
227
|
// M1 (2026-09-23): ten tools wrapping routes that already existed in api-v1.js / coord-dispatch-lib.js /
|
|
209
228
|
// receipt.js but had no MCP tool. tascan_verify_receipt: receipt.js's POST /receipts/verify is a PUBLIC
|
|
210
229
|
// route (no scope check at all inside receipt.js) — read is the floor because mcp-endpoint.js still
|
|
@@ -250,7 +269,14 @@ const TOOL_SCOPES = {
|
|
|
250
269
|
tascan_list_devices: R, tascan_get_device: R,
|
|
251
270
|
tascan_revoke_device: { tier: 'full', device_admin: true },
|
|
252
271
|
// B1 (migration 220): GET /usage wrapped as a tool — read tier, same as every other read-only usage/analytics tool.
|
|
253
|
-
tascan_get_usage: R
|
|
272
|
+
tascan_get_usage: R,
|
|
273
|
+
// B2 (migration 221): per-key budgets + delegated child keys. tascan_get_budget is the caller's own row (read
|
|
274
|
+
// tier, same idiom as tascan_get_usage). tascan_delegate_to_agent mints (NOT destructiveHint — see tools.cjs);
|
|
275
|
+
// tascan_revoke_delegation is destructive+idempotent. Both need write + delegate (owner-only to grant, I-6).
|
|
276
|
+
tascan_get_budget: R,
|
|
277
|
+
tascan_delegate_to_agent: { tier: 'write', delegate: true },
|
|
278
|
+
tascan_list_delegations: R,
|
|
279
|
+
tascan_revoke_delegation: { tier: 'write', delegate: true }
|
|
254
280
|
};
|
|
255
281
|
// Tools classified here that do not yet exist in tools.cjs (other workstreams' patches). Empty since
|
|
256
282
|
// tascan_get_receipt landed in tools.cjs (S100 phase 2b); harnesses assert TOOL_SCOPES ⊇ tools.cjs.
|
|
@@ -336,6 +362,12 @@ const ROUTE_SCOPES = {
|
|
|
336
362
|
// coordination layer (api-v1-helpers.js parsePath handlers task_messages / coord_cycles / coord_cycle_report / build / build_file)
|
|
337
363
|
task_messages: { GET: R, POST: CYCLE_WRITE }, // trail messages; POST answer on a dispatcher question → coord_answer_question
|
|
338
364
|
coord_cycles: { GET: R, POST: { tier: 'write', dispatch: 'code' } }, // list roots / coord_create_cycle (T1 = CODE:/SHELL: on the AI Inbox)
|
|
365
|
+
// Fan-out phase 1a-ii (master item 4): matched in coord-fanout-lib.js's own matchFanoutRoute, not
|
|
366
|
+
// api-v1-helpers.js's parsePath ROUTES table — same D6/D8 idiom as coord_dispatcher_action/coord_build_diff below.
|
|
367
|
+
// coordFanoutLib.handleFanoutRoute self-gates with these exact shapes too (belt-and-suspenders).
|
|
368
|
+
coord_fanouts: { GET: R, POST: { tier: 'write', dispatch: 'code' } }, // GET /coord/fanouts (list) / POST (create, T_F = CODE:/SHELL:-equivalent dispatch)
|
|
369
|
+
coord_fanout: { GET: R }, // GET /coord/fanouts/:id[?view=status|report|rollup|verify]
|
|
370
|
+
coord_fanout_control: { POST: { tier: 'write', dispatch: 'code' } }, // POST /coord/fanouts/:id/control (pause|resume|cancel|close_barrier)
|
|
339
371
|
coord_integrate: { POST: WD }, // D4 (181): POST /coord/cycles/:root/integrate — the dispatcher records the deploy id (agent:dispatch)
|
|
340
372
|
coord_dispatcher_action: { POST: WD }, // D6 (194): POST /coord/cycles/:root/dispatcher-actions — a chief-of-staff action becomes a receipt (agent:dispatch); the handler self-gates too
|
|
341
373
|
coord_build_diff: { POST: WD }, // D8 item 6 (196): POST /coord/builds/:ref/diff — the dispatcher stores an already-computed diff (agent:dispatch); the handler self-gates too
|
|
@@ -352,6 +384,16 @@ const ROUTE_SCOPES = {
|
|
|
352
384
|
// V9b (204): GET/PUT /evidence/policy/:task_id — read a task's evidence-policy progress / author + pin one.
|
|
353
385
|
evidence_policy: { GET: R, PUT: W }, // GET /builds/:build_ref/file?path=&offset=&limit= ≤ 12,000 chars per call
|
|
354
386
|
keys: 'admin', key: 'admin',
|
|
387
|
+
// B2 (migration 221): key_budget (PUT/GET /keys/:id/budget) joins the admin-JWT-only idiom (api-v1.js's
|
|
388
|
+
// DECISION_ASSERTION_ADMIN_HANDLERS-style set) — a tsk_/service bearer is refused 403 human_session_required
|
|
389
|
+
// before this table is ever consulted (I-3: no key, ever, sets or reads ANOTHER key's budget); the whole entry
|
|
390
|
+
// is the 'admin' marker, same as keys/key above, since the handler self-gates (owner-only for PUT, any admin
|
|
391
|
+
// role for GET). budget (GET /budget, no :id) is the CALLING key's own row — an ordinary read-tier key route.
|
|
392
|
+
// delegations/delegation need write + delegate (owner-only to grant, I-6).
|
|
393
|
+
key_budget: 'admin',
|
|
394
|
+
budget: { GET: R },
|
|
395
|
+
delegations: { POST: { tier: 'write', delegate: true }, GET: R },
|
|
396
|
+
delegation: { DELETE: { tier: 'write', delegate: true } },
|
|
355
397
|
// H1 (migration 215): human decision binding — admin-JWT-only, same gate-skip idiom as keys/key (api-v1.js's
|
|
356
398
|
// DECISION_ASSERTION_ADMIN_HANDLERS uses authenticateAdminSession and refuses a tsk_/service key 403 human_session_required).
|
|
357
399
|
decision_assertion_options: 'admin', decision_assertions: 'admin', decision_assertion: 'admin',
|
|
@@ -387,6 +429,7 @@ const SCOPE_LABEL = {
|
|
|
387
429
|
[DISPATCH]: 'the "agent:dispatch" permission ("Let this app hand tasks to my AI agents")',
|
|
388
430
|
[DISPATCH_CODE]: 'the "agent:dispatch:code" permission ("...including code and shell tasks on my computer")',
|
|
389
431
|
[DEVICE_ADMIN]: 'the "device:admin" permission (register/rotate/revoke devices) — owner-only, requires full, and not reachable through OAuth',
|
|
432
|
+
[DELEGATE]: 'the "delegate" permission (mint time-limited child API keys) — owner-only, requires full, and not reachable through OAuth',
|
|
390
433
|
unclassified: 'a classification (this tool is not in the scope map)'
|
|
391
434
|
};
|
|
392
435
|
function reconnectHint(required) {
|
|
@@ -415,7 +458,7 @@ function restDenialHeaders(d) {
|
|
|
415
458
|
}
|
|
416
459
|
|
|
417
460
|
module.exports = {
|
|
418
|
-
SCOPE_VALUES, ALL_SCOPES, TIERS, RANK, DISPATCH, DISPATCH_CODE, DEVICE_ADMIN, AGENT_INBOX_IDS,
|
|
461
|
+
SCOPE_VALUES, ALL_SCOPES, TIERS, RANK, DISPATCH, DISPATCH_CODE, DEVICE_ADMIN, DELEGATE, AGENT_INBOX_IDS,
|
|
419
462
|
TASK_TYPES, LOCAL_TASK_TYPES, CLOUD_TASK_TYPES, REVIEWER_TASK_TYPES, LOCAL_ONLY_TASK_TYPES, CODE_TASK_TYPES, taskType, dispatchScopeFor, dispatchKind,
|
|
420
463
|
normalize, toArray, toScopeString, parseRequested, echoScope, SCOPE_PARAM_MAX, clamp, validateStored, check,
|
|
421
464
|
TOOL_SCOPES, PENDING_TOOLS, ROUTE_SCOPES, requiredForTool, requiredForRoute,
|
package/tools-fanout.cjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Fan-out phase 1b (2026-09-28, briefs/2026-09-28-fanout-phase1b-mcp-tools.md; master
|
|
2
|
+
// briefs/2026-09-27-fanout-phase1.md, r3): three MCP tools over the 1a-ii REST surface
|
|
3
|
+
// (coord-fanout-lib.js's matchFanoutRoute / handleFanoutRoute, wired into api-v1.js) -- never Supabase
|
|
4
|
+
// directly, same split-file pattern as tools-devices.cjs (spread into TOOLS by one line there).
|
|
5
|
+
//
|
|
6
|
+
// tascan_create_fanout: POST /coord/fanouts. Every preflight problem the route can see (fanout_ceiling_unset,
|
|
7
|
+
// over_ceiling, cap_below_plan, idempotency_body_mismatch, fanout_open_limit, seats_invalid, reserve_floor_unset,
|
|
8
|
+
// no_authority, key_required) reaches the caller VERBATIM: api() (tascan-mcp/index.js / mcp-endpoint.js) throws
|
|
9
|
+
// Error(data.error) on a non-2xx response and the transport prints "Error: <message>" unchanged -- this file adds
|
|
10
|
+
// no try/catch around that path on purpose. dry_run is forwarded to the route as-is (literal passthrough, the
|
|
11
|
+
// brief's own word); the route built in 1a-ii has NO server-side preflight mode for this path (unlike
|
|
12
|
+
// tascan_create_cycle's dry_run, which /coord/cycles DOES implement) -- a create call with dry_run true still
|
|
13
|
+
// creates a REAL fan-out today. Documented here, in the tool description and at runtime, rather than silently
|
|
14
|
+
// guessed: fixing that gap means touching coord-fanout-lib.js, which is out of this brief's six deliverables.
|
|
15
|
+
//
|
|
16
|
+
// tascan_get_fanout: GET /coord/fanouts/:id?view=. tascan_control_fanout: POST /coord/fanouts/:id/control, action
|
|
17
|
+
// enum WITHOUT amend and WITHOUT answer (master: no such actions in phase 1) -- exactly coord-fanout-lib.js's own
|
|
18
|
+
// CONTROL_ACTIONS.
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
22
|
+
const VIEWS = ['status', 'report', 'rollup', 'verify'];
|
|
23
|
+
const CONTROL_ACTIONS = ['pause', 'resume', 'cancel', 'close_barrier'];
|
|
24
|
+
|
|
25
|
+
module.exports = [
|
|
26
|
+
{
|
|
27
|
+
name: 'tascan_create_fanout',
|
|
28
|
+
description: 'Create a fan-out: 1-30 RESEARCH seats plus one synthesizer under ONE cycle root T_F (POST /coord/fanouts). Zero Decision cards, zero Parked cards, zero pages per seat -- only a control action or the closing roll-up reaches a human. Requires an API key with agent:dispatch:code -- an admin session has no dispatch budget (key_required). Consumes seats.length + 1 dispatch units BEFORE the authoritative create; a refusal only the RPC can see (cap_below_plan, fanout_open_limit, bad agent_id, reserve_floor_unset) still consumes them, by design. Fails closed fanout_ceiling_unset until the owner sets a ceiling; over_ceiling when cap exceeds it. idempotency_key replays duplicate within 24h on the same plan, else idempotency_body_mismatch. dry_run is forwarded as-is; the route has no preflight mode yet, so dry_run true still creates for real -- never a way to avoid creating. Leaves fanout_created; every seat, the synth and the roll-up leave their own signed completion. Track with tascan_get_fanout.',
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
project_id: { type: 'string', description: 'Working project (UUID). The fan-out root T_F is minted on its Questions list (coord_ensure_lists).' },
|
|
33
|
+
title: { type: 'string', description: 'Short human title (1-200 chars). T_F becomes "FANOUT: <title>"; seats become "RESEARCH: [seat-NN] <title>"; the synth becomes "RESEARCH: [synth] <title>".' },
|
|
34
|
+
seats: {
|
|
35
|
+
type: 'array',
|
|
36
|
+
description: '1-30 seats. Each names the agent that runs it; brief/role fall back to seat_defaults when omitted.',
|
|
37
|
+
items: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
agent_id: { type: 'string', description: 'A registered agent_registry id in this org, matching ^[a-z][a-z0-9-]{0,39}$. Never a system-* worker.' },
|
|
41
|
+
brief: { type: 'string', description: 'This seat\'s own prompt (1-40000 chars). Falls back to seat_defaults.brief when omitted -- one of the two is required.' },
|
|
42
|
+
role: { type: 'string', description: 'Optional label for this seat (1-100 chars), e.g. "topic A".' }
|
|
43
|
+
},
|
|
44
|
+
required: ['agent_id']
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
seat_defaults: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
description: 'Shared defaults applied when a seat names no brief of its own.',
|
|
50
|
+
properties: {
|
|
51
|
+
brief: { type: 'string', description: 'Default seat prompt (1-40000 chars), used by any seat that names no brief of its own.' },
|
|
52
|
+
est_micro_usd: { type: 'integer', description: 'Positive integer micro-USD estimate per seat, used by the RPC\'s cap_below_plan check.' },
|
|
53
|
+
review_est_micro_usd: { type: 'integer', description: 'Non-negative integer micro-USD estimate per seat review. Must be > 0 when seat_review is per_seat.' }
|
|
54
|
+
},
|
|
55
|
+
required: ['est_micro_usd']
|
|
56
|
+
},
|
|
57
|
+
synth: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
description: 'The synthesizer that runs once the barrier closes, reading every done seat\'s stored output as DATA.',
|
|
60
|
+
properties: {
|
|
61
|
+
brief: { type: 'string', description: 'The synthesizer\'s prompt (1-40000 chars).' },
|
|
62
|
+
max_cost_micro_usd: { type: 'integer', description: 'Positive integer micro-USD reserved for the synth run; reserved out of cap up front, released back into the seat budget only at barrier close.' }
|
|
63
|
+
},
|
|
64
|
+
required: ['brief', 'max_cost_micro_usd']
|
|
65
|
+
},
|
|
66
|
+
canary: { type: 'integer', description: 'How many seats release immediately; the rest stay held on T_F until the first canary settles done. Default min(2, seats.length).' },
|
|
67
|
+
min_ok: { type: 'integer', description: 'Minimum seats that must finish done before the synth barrier can close. Default ceil(seats.length / 2).' },
|
|
68
|
+
breaker_k: { type: 'integer', description: 'Paid (non-infrastructure) seat failures before the breaker pauses the fan-out (2-5, default 2). An expired run never counts.' },
|
|
69
|
+
deadline_min: { type: 'integer', description: 'Minutes before an idle seat is dropped and the barrier re-evaluated, and before a stuck synthesizing fan-out closes deadline (10-1440, default 240).' },
|
|
70
|
+
seat_review: { type: 'string', enum: ['per_seat', 'none'], description: 'per_seat mints one review per seat (allowed for at most 10 seats); none skips seat review. Default per_seat for <=10 seats, none above that.' },
|
|
71
|
+
cap: { type: 'integer', description: 'This fan-out\'s total micro-USD budget, synth reserve included. Refused over_ceiling if it exceeds the org\'s fan-out spending ceiling.' },
|
|
72
|
+
idempotency_key: { type: 'string', description: 'Optional replay key (<= 200 chars). The same key within 24h on the same plan returns duplicate:true with the existing fanout_id; on a different plan it is refused idempotency_body_mismatch.' },
|
|
73
|
+
dry_run: { type: 'boolean', description: 'Forwarded to POST /coord/fanouts as given. The route has no server-side preflight for this path today, so true still performs a REAL create -- do not rely on it to avoid creating a fan-out.' }
|
|
74
|
+
},
|
|
75
|
+
required: ['project_id', 'title', 'seats', 'synth', 'cap']
|
|
76
|
+
},
|
|
77
|
+
annotations: { title: 'Create Fan-out', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
78
|
+
handler: async (args, api) => {
|
|
79
|
+
const projectId = String(args.project_id || '').trim();
|
|
80
|
+
if (!UUID_RE.test(projectId)) throw new Error('project_id must be a UUID');
|
|
81
|
+
if (!args.title || typeof args.title !== 'string') throw new Error('title is required');
|
|
82
|
+
if (!Array.isArray(args.seats) || !args.seats.length) throw new Error('seats is required (1-30 entries)');
|
|
83
|
+
if (!Number.isInteger(args.cap) || args.cap <= 0) throw new Error('cap is required (a positive integer, this fan-out\'s total micro-USD budget)');
|
|
84
|
+
if (!args.synth || typeof args.synth !== 'object') throw new Error('synth is required ({brief, max_cost_micro_usd})');
|
|
85
|
+
|
|
86
|
+
const body = {
|
|
87
|
+
project_id: projectId,
|
|
88
|
+
title: args.title,
|
|
89
|
+
seats: args.seats.map(s => ({
|
|
90
|
+
agent_id: s && s.agent_id,
|
|
91
|
+
...(s && s.brief != null ? { brief: s.brief } : {}),
|
|
92
|
+
...(s && s.role != null ? { role: s.role } : {})
|
|
93
|
+
})),
|
|
94
|
+
synth: { brief: args.synth.brief, max_cost_micro_usd: args.synth.max_cost_micro_usd },
|
|
95
|
+
cap: args.cap
|
|
96
|
+
};
|
|
97
|
+
if (args.seat_defaults) body.seat_defaults = args.seat_defaults;
|
|
98
|
+
for (const k of ['canary', 'min_ok', 'breaker_k', 'deadline_min', 'seat_review', 'idempotency_key']) {
|
|
99
|
+
if (args[k] != null) body[k] = args[k];
|
|
100
|
+
}
|
|
101
|
+
const dryRun = args.dry_run === true || args.dry_run === 'true';
|
|
102
|
+
if (args.dry_run != null) body.dry_run = dryRun;
|
|
103
|
+
|
|
104
|
+
const result = await api('POST', '/coord/fanouts', body, args.idempotency_key);
|
|
105
|
+
const d = result.data || {};
|
|
106
|
+
if (d.duplicate) {
|
|
107
|
+
return `DUPLICATE — nothing was created. A fan-out with the same idempotency_key and plan exists from the last 24h.\nfanout_id: ${d.fanout_id}\n\nRead it: tascan_get_fanout fanout_id=${d.fanout_id}`;
|
|
108
|
+
}
|
|
109
|
+
const seatLines = Array.isArray(d.seats)
|
|
110
|
+
? d.seats.map(s => ` seat-${String(s.seat_no).padStart(2, '0')}: ${s.seat_id} (${s.released ? 'released' : 'held on T_F'}, ${s.status})`).join('\n')
|
|
111
|
+
: ' (none returned)';
|
|
112
|
+
let text = `Fan-out created.\nfanout_id (T_F): ${d.fanout_id}\nplan_sha256: ${d.plan_sha256}\nstatus: ${d.status}\nsynth_task_id: ${d.synth_task_id}\npoll_after_s: ${d.poll_after_s}\nseats:\n${seatLines}\n\nFollow with tascan_get_fanout fanout_id=${d.fanout_id} (view=status).`;
|
|
113
|
+
if (dryRun) {
|
|
114
|
+
text = 'NOTE: dry_run has no server-side preflight on this route yet — the fan-out below was created for REAL, not simulated.\n\n' + text;
|
|
115
|
+
}
|
|
116
|
+
return text;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'tascan_get_fanout',
|
|
121
|
+
description: 'Read a fan-out (GET /coord/fanouts/:id?view=). view=status (default): state, seat counts, spend, cap, synth_deadline_at, poll_after_s. view=report: the full cycle report (get_cycle_report) — every seat, review and control step. view=rollup: the exact roll-up response_value plus its leaves (path, sha256, completion_id) once closed. view=verify: the server recomputes rollup_ref over the frozen leaves and returns ok / mismatches — the same check scripts/verify-fanout.js performs independently from another machine, which is the trusted proof; this view is a convenience, not a substitute. A foreign org\'s fanout_id answers 404, never 403, so another org\'s fan-out never even appears to exist. Read tier, no dispatch permission required.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
fanout_id: { type: 'string', description: 'Fan-out id (UUID) — the T_F root, from tascan_create_fanout or tascan_list_cycles.' },
|
|
126
|
+
view: { type: 'string', enum: VIEWS, description: 'status (default), report, rollup or verify.' }
|
|
127
|
+
},
|
|
128
|
+
required: ['fanout_id']
|
|
129
|
+
},
|
|
130
|
+
annotations: { title: 'Get Fan-out', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
131
|
+
handler: async (args, api) => {
|
|
132
|
+
const id = String(args.fanout_id || '').trim();
|
|
133
|
+
if (!UUID_RE.test(id)) throw new Error('fanout_id must be a UUID');
|
|
134
|
+
const view = args.view != null && args.view !== '' ? String(args.view) : 'status';
|
|
135
|
+
if (!VIEWS.includes(view)) throw new Error(`view must be one of: ${VIEWS.join(', ')}`);
|
|
136
|
+
const result = await api('GET', `/coord/fanouts/${id}?view=${view}`);
|
|
137
|
+
const out = `Fan-out ${id} (view=${view}):\n\n${JSON.stringify(result.data, null, 2)}`;
|
|
138
|
+
return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'tascan_control_fanout',
|
|
143
|
+
description: 'Control a running fan-out (POST /coord/fanouts/:id/control). pause holds every unclaimed seat and stops the orphan pass from re-firing them; resume releases them again; cancel supersedes every unclaimed task and closes once quiescent; close_barrier drops unsettled or named exclude_seats and forces the synth barrier now instead of waiting for more seats. No amend and no answer in phase 1. Every action mints a signed control-child completion FIRST, as the org\'s own system worker (coord_fanout_control_receipt) — never coord_dispatcher_action, so a control action can never sign into another org\'s Chief of Staff identity. idempotency_key replays the prior outcome (replayed:true) and applies nothing twice; a replayed resume after a later pause leaves the fan-out paused. Refused fanout_closed once the roll-up has closed. Requires agent:dispatch:code.',
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
fanout_id: { type: 'string', description: 'Fan-out id (UUID) — the T_F root.' },
|
|
148
|
+
action: { type: 'string', enum: CONTROL_ACTIONS, description: 'pause, resume, cancel or close_barrier. No amend, no answer (phase 1).' },
|
|
149
|
+
reason: { type: 'string', description: 'Optional free-text reason (<= 4000 chars), recorded on the control child\'s completion.' },
|
|
150
|
+
exclude_seats: { type: 'array', items: { type: 'integer' }, description: 'close_barrier only: 1-based seat numbers to drop from the barrier instead of waiting for them to settle.' },
|
|
151
|
+
idempotency_key: { type: 'string', description: 'Optional replay key (<= 200 chars). The same key returns the prior outcome (replayed:true) and applies nothing a second time.' }
|
|
152
|
+
},
|
|
153
|
+
required: ['fanout_id', 'action']
|
|
154
|
+
},
|
|
155
|
+
annotations: { title: 'Control Fan-out', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
156
|
+
handler: async (args, api) => {
|
|
157
|
+
const id = String(args.fanout_id || '').trim();
|
|
158
|
+
if (!UUID_RE.test(id)) throw new Error('fanout_id must be a UUID');
|
|
159
|
+
const action = String(args.action || '');
|
|
160
|
+
if (!CONTROL_ACTIONS.includes(action)) throw new Error(`action must be one of: ${CONTROL_ACTIONS.join(', ')}`);
|
|
161
|
+
const body = { action };
|
|
162
|
+
if (args.reason != null) body.reason = args.reason;
|
|
163
|
+
if (args.exclude_seats != null) {
|
|
164
|
+
if (!Array.isArray(args.exclude_seats) || !args.exclude_seats.every(n => Number.isInteger(n) && n > 0)) {
|
|
165
|
+
throw new Error('exclude_seats must be an array of positive integers');
|
|
166
|
+
}
|
|
167
|
+
body.exclude_seats = args.exclude_seats;
|
|
168
|
+
}
|
|
169
|
+
if (args.idempotency_key != null) body.idempotency_key = args.idempotency_key;
|
|
170
|
+
const result = await api('POST', `/coord/fanouts/${id}/control`, body, args.idempotency_key);
|
|
171
|
+
const d = result.data || {};
|
|
172
|
+
return `${d.replayed ? 'REPLAYED (idempotent — nothing applied twice)' : 'Applied'}: ${action} on fan-out ${id}.\nfanout_status: ${d.fanout_status}\nreceipt_completion_id: ${d.receipt_completion_id}\n\n${JSON.stringify(d, null, 2)}`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
];
|
package/tools.cjs
CHANGED
|
@@ -2750,8 +2750,83 @@ const TOOLS = [
|
|
|
2750
2750
|
return lines.join('\n');
|
|
2751
2751
|
}
|
|
2752
2752
|
},
|
|
2753
|
+
// B2 (migration 221): per-key action budgets (#10, I-3) + delegated, time-limited child keys (#11, I-6, I-10).
|
|
2754
|
+
{
|
|
2755
|
+
name: 'tascan_get_budget',
|
|
2756
|
+
description: 'Read THIS credential\'s own per-key action budget (GET /budget): daily limits (sms, invites, payment-request cents per day and per request, dispatch, human pages) and today\'s spend against them. A NULL limit (or no budget row at all) means unlimited on that counter. A key can only ever see its OWN budget, never another key\'s (I-3, no tool or key ever reads another key\'s row). Read tier.',
|
|
2757
|
+
inputSchema: { type: 'object', properties: {} },
|
|
2758
|
+
annotations: { title: 'Get Budget', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
2759
|
+
handler: async (args, api) => {
|
|
2760
|
+
const result = await api('GET', '/budget');
|
|
2761
|
+
const d = result.data || {};
|
|
2762
|
+
const b = d.budget || {}, u = d.usage || {};
|
|
2763
|
+
const rows = [
|
|
2764
|
+
['sms_per_day', 'sms_count', 'sms/day'], ['invites_per_day', 'invites_count', 'invites/day'],
|
|
2765
|
+
['payment_request_cents_per_day', 'payment_request_cents_count', 'payment request cents/day'],
|
|
2766
|
+
['payment_request_cents_max', null, 'payment request cents/request (max)'],
|
|
2767
|
+
['dispatch_per_day', 'dispatch_count', 'dispatch/day'], ['human_pages_per_day', 'human_pages_count', 'human pages/day']
|
|
2768
|
+
];
|
|
2769
|
+
const lines = ["this key's budget (null = unlimited):"];
|
|
2770
|
+
for (const [limitKey, usageKey, label] of rows) {
|
|
2771
|
+
const limit = b[limitKey] != null ? b[limitKey] : 'unlimited';
|
|
2772
|
+
const used = usageKey ? (u[usageKey] != null ? u[usageKey] : 0) : null;
|
|
2773
|
+
lines.push(` ${label}: ${used != null ? used + ' / ' : ''}${limit}`);
|
|
2774
|
+
}
|
|
2775
|
+
return lines.join('\n') + '\n\n' + JSON.stringify(d, null, 2);
|
|
2776
|
+
}
|
|
2777
|
+
},
|
|
2778
|
+
{
|
|
2779
|
+
name: 'tascan_delegate_to_agent',
|
|
2780
|
+
description: 'Mint a time-limited, NARROWER child API key (POST /delegations, roadmap #11) — only read/write scopes may be requested; full, agent:dispatch, agent:dispatch:code, device:admin, delegate and webhooks:manage are always refused by name (I-6). TTL defaults to 1 hour (3600s), clamps to [60 seconds, 24 hours], and clamps further to THIS key\'s own expiry if it has one — a delegated key can never outlive its grantor. The child\'s tier is always a SUBSET of this key\'s own effective tier, never wider or lateral. Requires the delegate permission (owner-only to grant, requires full). The returned raw key is shown to you exactly ONCE — it is never stored server-side and cannot be retrieved again (I-10). Revoking this key (or any ancestor) cascades immediately to every key it delegated.',
|
|
2781
|
+
inputSchema: {
|
|
2782
|
+
type: 'object',
|
|
2783
|
+
properties: {
|
|
2784
|
+
scopes: { type: 'array', items: { type: 'string', enum: ['read', 'write'] }, description: 'Scopes to request for the child key — read and/or write only.' },
|
|
2785
|
+
ttl_seconds: { type: 'number', description: 'How long the child key lives, in seconds (default 3600 = 1h; clamped to [60, 86400] and to this key\'s own expiry).' },
|
|
2786
|
+
resource_constraints: { type: 'object', description: 'Optional free-form narrowing (e.g. project/list ids) recorded on the child key as metadata.' }
|
|
2787
|
+
},
|
|
2788
|
+
required: ['scopes']
|
|
2789
|
+
},
|
|
2790
|
+
annotations: { title: 'Delegate To Agent', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
2791
|
+
handler: async (args, api) => {
|
|
2792
|
+
if (!Array.isArray(args.scopes) || !args.scopes.length) throw new Error('scopes is required (array of "read"/"write")');
|
|
2793
|
+
const result = await api('POST', '/delegations', { scopes: args.scopes, ttl_seconds: args.ttl_seconds, resource_constraints: args.resource_constraints });
|
|
2794
|
+
const d = result.data || {};
|
|
2795
|
+
return `Delegated key ${d.key_id} minted, scopes [${(d.scopes || []).join(', ')}], expires ${d.expires_at}.\nSAVE THIS KEY NOW — it will not be shown again:\n${d.key}`;
|
|
2796
|
+
}
|
|
2797
|
+
},
|
|
2798
|
+
{
|
|
2799
|
+
name: 'tascan_list_delegations',
|
|
2800
|
+
description: 'List delegated child keys (GET /delegations): this key\'s own direct children (id, label, scopes, active/revoked, depth, expires_at) — never the raw credential. An admin session sees every delegated key in the org. Read tier.',
|
|
2801
|
+
inputSchema: { type: 'object', properties: {} },
|
|
2802
|
+
annotations: { title: 'List Delegations', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
2803
|
+
handler: async (args, api) => {
|
|
2804
|
+
const result = await api('GET', '/delegations');
|
|
2805
|
+
const data = result.data || [];
|
|
2806
|
+
const out = `${data.length} delegated key(s)\n\n${JSON.stringify(data, null, 2)}`;
|
|
2807
|
+
return out.length > 12000 ? out.slice(0, 12000) + '\n…[truncated at 12000 chars]' : out;
|
|
2808
|
+
}
|
|
2809
|
+
},
|
|
2810
|
+
{
|
|
2811
|
+
name: 'tascan_revoke_delegation',
|
|
2812
|
+
description: 'Revoke a delegated child key immediately (DELETE /delegations/:key_id) — self-revoke, revoke by an ancestor key, or by an admin session of the org; any other caller is refused. Revoking cascades to every key IT delegated, in turn (the database trigger, not application code). Idempotent: revoking an already-revoked key reports already_revoked, no error.',
|
|
2813
|
+
inputSchema: {
|
|
2814
|
+
type: 'object',
|
|
2815
|
+
properties: { key_id: { type: 'string', description: 'The delegated key id to revoke (from tascan_list_delegations)' } },
|
|
2816
|
+
required: ['key_id']
|
|
2817
|
+
},
|
|
2818
|
+
annotations: { title: 'Revoke Delegation', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
2819
|
+
handler: async (args, api) => {
|
|
2820
|
+
if (!args.key_id) throw new Error('key_id is required');
|
|
2821
|
+
const result = await api('DELETE', `/delegations/${args.key_id}`);
|
|
2822
|
+
const d = result.data || {};
|
|
2823
|
+
return d.already_revoked ? `Key ${args.key_id} was already revoked.` : `Key ${args.key_id} revoked.`;
|
|
2824
|
+
}
|
|
2825
|
+
},
|
|
2753
2826
|
// P0 (migration 216): device control plane — split out (194 KB already close to the ~200 KB bundle cap).
|
|
2754
|
-
...require('./tools-devices.cjs')
|
|
2827
|
+
...require('./tools-devices.cjs'),
|
|
2828
|
+
// Fan-out phase 1b (2026-09-28): three tools over the 1a-ii REST surface — split out, same reason.
|
|
2829
|
+
...require('./tools-fanout.cjs')
|
|
2755
2830
|
];
|
|
2756
2831
|
|
|
2757
2832
|
module.exports = { TOOLS, AGENT_REGISTRY, dynamicAgents };
|