toolroll 0.9.24 → 0.9.26
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/CHANGELOG.md +24 -0
- package/dist/browser/workspace.js +1 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/flow-cadence.d.ts +42 -0
- package/dist/flow-cadence.js +78 -0
- package/dist/invoke.d.ts +3 -0
- package/dist/invoke.js +9 -4
- package/dist/mate-tools.js +1 -1
- package/dist/mcp-connect.d.ts +50 -7
- package/dist/mcp-connect.js +87 -8
- package/dist/operate.d.ts +3 -1
- package/dist/operate.js +95 -27
- package/dist/project-tools.d.ts +17 -0
- package/dist/project-tools.js +32 -4
- package/dist/provider.d.ts +3 -0
- package/dist/scout.d.ts +3 -2
- package/dist/scout.js +34 -8
- package/dist/serve.js +69 -15
- package/dist/tools-ui.d.ts +12 -1
- package/dist/tools-ui.js +34 -12
- package/package.json +1 -1
package/dist/desktop-update.d.ts
CHANGED
|
@@ -125,6 +125,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
125
125
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
126
126
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
127
127
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
128
|
+
version?: never;
|
|
128
129
|
active: boolean;
|
|
129
130
|
phase: string;
|
|
130
131
|
detail: string;
|
|
@@ -138,7 +139,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
138
139
|
wasRunning?: never;
|
|
139
140
|
backupPath?: never;
|
|
140
141
|
updateId?: never;
|
|
141
|
-
version?: never;
|
|
142
142
|
buildId?: never;
|
|
143
143
|
workDir?: never;
|
|
144
144
|
} | {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow passes that don't wait for builds. The worker's pass used to move
|
|
3
|
+
* cards only between builds, so a card whose research finished sat still
|
|
4
|
+
* while another build ran (Oct 4: five minutes). Now the worker runs these
|
|
5
|
+
* passes on their own cadence beside its builds, and a finished build,
|
|
6
|
+
* research or check moves its card at once.
|
|
7
|
+
*
|
|
8
|
+
* Model-free and cheap: nothing here starts a build. Cards entering a build
|
|
9
|
+
* zone file their task, which waits for a free slot like any other.
|
|
10
|
+
*/
|
|
11
|
+
import { type FlowAdvance } from "./flow-engine.js";
|
|
12
|
+
import { type TriggerIo, type TriggerPass } from "./flow-triggers.js";
|
|
13
|
+
import { type ReplyWatch, type ReplyWatchIo } from "./flow-replies.js";
|
|
14
|
+
import { type StepIo, type StepPass } from "./flow-steps.js";
|
|
15
|
+
import type { Store } from "./store.js";
|
|
16
|
+
/** How often the worker moves cards, checks triggers, reads replies and runs steps, builds or not. */
|
|
17
|
+
export declare const FLOW_EVERY_MS = 15000;
|
|
18
|
+
/** One pass of a kind for a project at a time in this process: a second one asked for while it runs is skipped
|
|
19
|
+
* (`idle`), and the next cadence takes up whatever it would have done. Cards are also claimed in the database,
|
|
20
|
+
* so passes in other processes can't act twice either. */
|
|
21
|
+
export declare function alone<T>(kind: string, project: string, idle: T, run: () => Promise<T>): Promise<T>;
|
|
22
|
+
export type FlowIo = {
|
|
23
|
+
triggers: TriggerIo;
|
|
24
|
+
replies: ReplyWatchIo;
|
|
25
|
+
steps: StepIo;
|
|
26
|
+
evidenceRoot?: string;
|
|
27
|
+
};
|
|
28
|
+
export type FlowHousekeeping = {
|
|
29
|
+
triggers: TriggerPass;
|
|
30
|
+
replies: ReplyWatch;
|
|
31
|
+
steps: StepPass;
|
|
32
|
+
flows: FlowAdvance;
|
|
33
|
+
};
|
|
34
|
+
/** One project's flow pass: triggers (which may start cards), replies (which move waiting cards), steps, then moves.
|
|
35
|
+
* `halted` (a stop or paused admission) skips everything that reaches outside; moves still run. */
|
|
36
|
+
export declare function flowHousekeeping(store: Store, repo: string, clock: () => Date, io: FlowIo, halted: () => boolean): Promise<FlowHousekeeping>;
|
|
37
|
+
/** Move one project's cards. Synchronous, so two can't overlap in a process; each move is checked against the
|
|
38
|
+
* card's visit in its own transaction, so a pass in another process can't move a card twice. */
|
|
39
|
+
export declare function moveCards(store: Store, repo: string, now: Date, evidenceRoot: string | undefined): FlowAdvance;
|
|
40
|
+
/** A build, research or check just finished: move the cards in this project, and in the project of any flow whose
|
|
41
|
+
* card that task belongs to (a build zone may build in another project), now rather than on the next pass. */
|
|
42
|
+
export declare function moveCardsAfter(store: Store, repo: string, tasks: readonly string[], now: Date, evidenceRoot: string | undefined): FlowAdvance;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow passes that don't wait for builds. The worker's pass used to move
|
|
3
|
+
* cards only between builds, so a card whose research finished sat still
|
|
4
|
+
* while another build ran (Oct 4: five minutes). Now the worker runs these
|
|
5
|
+
* passes on their own cadence beside its builds, and a finished build,
|
|
6
|
+
* research or check moves its card at once.
|
|
7
|
+
*
|
|
8
|
+
* Model-free and cheap: nothing here starts a build. Cards entering a build
|
|
9
|
+
* zone file their task, which waits for a free slot like any other.
|
|
10
|
+
*/
|
|
11
|
+
import { advanceFlows } from "./flow-engine.js";
|
|
12
|
+
import { runFlowTriggers } from "./flow-triggers.js";
|
|
13
|
+
import { watchFlowReplies } from "./flow-replies.js";
|
|
14
|
+
import { runFlowSteps } from "./flow-steps.js";
|
|
15
|
+
/** How often the worker moves cards, checks triggers, reads replies and runs steps, builds or not. */
|
|
16
|
+
export const FLOW_EVERY_MS = 15_000;
|
|
17
|
+
const running = new Set();
|
|
18
|
+
/** One pass of a kind for a project at a time in this process: a second one asked for while it runs is skipped
|
|
19
|
+
* (`idle`), and the next cadence takes up whatever it would have done. Cards are also claimed in the database,
|
|
20
|
+
* so passes in other processes can't act twice either. */
|
|
21
|
+
export async function alone(kind, project, idle, run) {
|
|
22
|
+
const key = `${kind}\u0000${project}`;
|
|
23
|
+
if (running.has(key))
|
|
24
|
+
return idle;
|
|
25
|
+
running.add(key);
|
|
26
|
+
try {
|
|
27
|
+
return await run();
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
running.delete(key);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** One project's flow pass: triggers (which may start cards), replies (which move waiting cards), steps, then moves.
|
|
34
|
+
* `halted` (a stop or paused admission) skips everything that reaches outside; moves still run. */
|
|
35
|
+
export async function flowHousekeeping(store, repo, clock, io, halted) {
|
|
36
|
+
const triggers = halted() ? { added: 0, checked: 0, problems: [] }
|
|
37
|
+
: await alone("triggers", repo, { added: 0, checked: 0, problems: [] }, () => runFlowTriggers(store, repo, clock(), io.triggers));
|
|
38
|
+
// One mailbox serves every project, so its reads are one at a time across them all.
|
|
39
|
+
const replies = halted() ? { read: 0, taken: 0, problem: null }
|
|
40
|
+
: await alone("replies", "*", { read: 0, taken: 0, problem: null }, () => watchFlowReplies(store, clock(), io.replies));
|
|
41
|
+
const steps = halted() ? { ran: 0, problems: [] }
|
|
42
|
+
: await alone("steps", repo, { ran: 0, problems: [] }, () => runFlowSteps(store, repo, clock(), io.steps));
|
|
43
|
+
// Last, so a step that just finished, or a card a trigger just added, moves now.
|
|
44
|
+
const flows = moveCards(store, repo, clock(), io.evidenceRoot);
|
|
45
|
+
return { triggers, replies, steps, flows };
|
|
46
|
+
}
|
|
47
|
+
/** Move one project's cards. Synchronous, so two can't overlap in a process; each move is checked against the
|
|
48
|
+
* card's visit in its own transaction, so a pass in another process can't move a card twice. */
|
|
49
|
+
export function moveCards(store, repo, now, evidenceRoot) {
|
|
50
|
+
try {
|
|
51
|
+
return advanceFlows(store, repo, now, evidenceRoot === undefined ? {} : { evidenceRoot });
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return { moved: 0, filed: [], problems: [`flows: ${error instanceof Error ? error.message : "could not move cards"}`] };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** A build, research or check just finished: move the cards in this project, and in the project of any flow whose
|
|
58
|
+
* card that task belongs to (a build zone may build in another project), now rather than on the next pass. */
|
|
59
|
+
export function moveCardsAfter(store, repo, tasks, now, evidenceRoot) {
|
|
60
|
+
const projects = new Set([repo]);
|
|
61
|
+
for (const task of tasks) {
|
|
62
|
+
try {
|
|
63
|
+
const card = store.flowCardByTask(task) ?? store.flowCardByTask(store.taskFamilyOf(task, null, true)?.root.id ?? task);
|
|
64
|
+
const flow = card === null ? null : store.getFlow(card.flow);
|
|
65
|
+
if (flow !== null)
|
|
66
|
+
projects.add(flow.repo);
|
|
67
|
+
}
|
|
68
|
+
catch { /* not a task with a card */ }
|
|
69
|
+
}
|
|
70
|
+
const outcome = { moved: 0, filed: [], problems: [] };
|
|
71
|
+
for (const project of projects) {
|
|
72
|
+
const moved = moveCards(store, project, now, evidenceRoot);
|
|
73
|
+
outcome.moved += moved.moved;
|
|
74
|
+
outcome.filed.push(...moved.filed);
|
|
75
|
+
outcome.problems.push(...moved.problems);
|
|
76
|
+
}
|
|
77
|
+
return outcome;
|
|
78
|
+
}
|
package/dist/invoke.d.ts
CHANGED
|
@@ -103,6 +103,9 @@ export declare function invokeAgent(store: Store, runId: number, spec: AgentSpec
|
|
|
103
103
|
* hold belongs to the coordinator, never to a promise chain that would
|
|
104
104
|
* stall the watch.
|
|
105
105
|
*/
|
|
106
|
+
/** What a held session may use of the project's tools: a research run, which may only read and isn't told here which
|
|
107
|
+
* actions are read-only, gets none at all; any other run gets them as launched. */
|
|
108
|
+
export declare function heldReadOnly(store: Store, runId: number): Readonly<Record<string, readonly string[]>> | undefined;
|
|
106
109
|
export declare function invokeHeldAgent(store: Store, runId: number, spec: AgentSpec, argv: readonly string[], options: import("./exec.js").RunOptions & {
|
|
107
110
|
socketPath: string;
|
|
108
111
|
cookie: string;
|
package/dist/invoke.js
CHANGED
|
@@ -292,7 +292,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
292
292
|
const isolatedDb = isolatedAgentDatabase(runId);
|
|
293
293
|
// The project's tools (v80): exactly its MCP servers, and none of the
|
|
294
294
|
// operator's global or a repository's own. A review keeps its isolation.
|
|
295
|
-
const tools = invocation.phase === "review" ? null : runTools(store, runId, spec, options.keyHome, clock);
|
|
295
|
+
const tools = invocation.phase === "review" ? null : runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools);
|
|
296
296
|
// The agent fence: Toolroll's own secrets, database and other runs'
|
|
297
297
|
// evidence stay out of reach, whatever the permission mode. A review is
|
|
298
298
|
// already confined to its sealed files.
|
|
@@ -583,6 +583,11 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
|
|
|
583
583
|
* hold belongs to the coordinator, never to a promise chain that would
|
|
584
584
|
* stall the watch.
|
|
585
585
|
*/
|
|
586
|
+
/** What a held session may use of the project's tools: a research run, which may only read and isn't told here which
|
|
587
|
+
* actions are read-only, gets none at all; any other run gets them as launched. */
|
|
588
|
+
export function heldReadOnly(store, runId) {
|
|
589
|
+
return store.getRun(runId)?.role === "scout" ? {} : undefined;
|
|
590
|
+
}
|
|
586
591
|
export async function invokeHeldAgent(store, runId, spec, argv, options) {
|
|
587
592
|
const clock = options.clock ?? (() => new Date());
|
|
588
593
|
const run = store.getRun(runId);
|
|
@@ -624,7 +629,7 @@ export async function invokeHeldAgent(store, runId, spec, argv, options) {
|
|
|
624
629
|
const isolatedDb = isolatedAgentDatabase(runId);
|
|
625
630
|
// The project's tools (v80), as for every build: exactly its MCP servers;
|
|
626
631
|
// and the agent fence around Toolroll's own secrets.
|
|
627
|
-
const heldTools = runTools(store, runId, spec, keyHome, clock);
|
|
632
|
+
const heldTools = runTools(store, runId, spec, keyHome, clock, heldReadOnly(store, runId));
|
|
628
633
|
const heldFence = runFence(store, runId, keyHome);
|
|
629
634
|
argv = [...argv, ...heldTools.argv, ...(heldFence.length > 0 ? ["--settings", claudeFenceSettings(heldFence)] : [])];
|
|
630
635
|
const heldLaunch = fenceLaunch("claude", heldFence);
|
|
@@ -786,9 +791,9 @@ function fenceLaunch(provider, fence) {
|
|
|
786
791
|
return { wrap: [], method: provider === "claude" ? "claude-rules" : "none" };
|
|
787
792
|
}
|
|
788
793
|
/** One launch's tools, or — when they cannot be prepared — the same isolation with none, never the operator's global servers. */
|
|
789
|
-
function runTools(store, runId, spec, keyHome, clock) {
|
|
794
|
+
function runTools(store, runId, spec, keyHome, clock, readOnly) {
|
|
790
795
|
try {
|
|
791
|
-
return prepareRunTools(store, runId, spec.provider, { ...(keyHome === undefined ? {} : { home: keyHome }), now: clock(), includeModel: spec.model === null });
|
|
796
|
+
return prepareRunTools(store, runId, spec.provider, { ...(keyHome === undefined ? {} : { home: keyHome }), now: clock(), includeModel: spec.model === null, ...(readOnly === undefined ? {} : { readOnly }) });
|
|
792
797
|
}
|
|
793
798
|
catch {
|
|
794
799
|
return noToolsArgs(spec.provider, spec.model === null);
|
package/dist/mate-tools.js
CHANGED
|
@@ -761,7 +761,7 @@ export const MATE_TOOLS = [
|
|
|
761
761
|
},
|
|
762
762
|
{
|
|
763
763
|
name: "get_project_tools",
|
|
764
|
-
description: "Read a project's tools (the MCP servers its builds get, and only those), the services that connect by signing in, the common tools list and servers found on this computer. A service in connectBySigningIn (Stripe, Notion, Linear, Sentry, Jira…) connects in one click: the person signs in on the service's own page, so give them its connect link and never propose tool_add for it. Add/remove others with propose_action tool_add/tool_remove; secrets are set only on the Tools page.",
|
|
764
|
+
description: "Read a project's tools (the MCP servers its builds get, and only those), the services that connect by signing in, the common tools list and servers found on this computer. A service in connectBySigningIn (Stripe, Notion, Linear, Sentry, Jira…) connects in one click: the person signs in on the service's own page, so give them its connect link (for this repo; another project has its own) and never propose tool_add for it. When saying a service is connected, name the project it is connected to (its list_repos name). Add/remove others with propose_action tool_add/tool_remove; secrets are set only on the Tools page.",
|
|
765
765
|
inputSchema: schema({ repo: REPO_ARG }, ["repo"]),
|
|
766
766
|
handle: (ctx, args) => {
|
|
767
767
|
const repo = repoPathOf(ctx.who, args["repo"]);
|
package/dist/mcp-connect.d.ts
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
|
-
import { type ToolSpec } from "./project-tools.js";
|
|
1
|
+
import { type ProjectTool, type ToolSpec } from "./project-tools.js";
|
|
2
2
|
import type { Store } from "./store.js";
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* A one-click service. `reads` names what a research step may read there:
|
|
5
|
+
* an action is read-only only when it is named by a reading verb (READING)
|
|
6
|
+
* and every other word in its name is one of these.
|
|
7
|
+
*/
|
|
8
|
+
export type OneClick = {
|
|
5
9
|
id: string;
|
|
6
10
|
label: string;
|
|
7
11
|
url: string;
|
|
8
12
|
about: string;
|
|
9
|
-
|
|
13
|
+
reads?: readonly string[];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Services that connect this way: their servers speak streamable HTTP and
|
|
17
|
+
* advertise registering clients on the spot. Advertising isn't accepting:
|
|
18
|
+
* Figma's server advertises it, but its registration answers 403 to any app
|
|
19
|
+
* Figma hasn't approved, so Figma connects through its desktop app instead
|
|
20
|
+
* (LOCAL_APPS). A service is listed only when its registration accepts
|
|
21
|
+
* Toolroll; mcp-connect.test.ts replays each one's answer (no live calls).
|
|
22
|
+
*
|
|
23
|
+
* Signed in end to end, live: Mobbin. The rest register in replayed checks
|
|
24
|
+
* only, until someone signs in to each for real.
|
|
25
|
+
*/
|
|
26
|
+
export declare const ONE_CLICK: readonly OneClick[];
|
|
10
27
|
/**
|
|
11
28
|
* The list, with one service standing in on this computer when the person
|
|
12
29
|
* running Toolroll says so ("stripe|Stripe|http://127.0.0.1:5123/mcp"
|
|
@@ -14,19 +31,45 @@ export declare const ONE_CLICK: readonly {
|
|
|
14
31
|
* real without a real account. Only a loopback address is taken.
|
|
15
32
|
*/
|
|
16
33
|
export declare function oneClickServices(environment?: NodeJS.ProcessEnv): typeof ONE_CLICK;
|
|
17
|
-
export declare const oneClickOf: (id: string) =>
|
|
34
|
+
export declare const oneClickOf: (id: string) => OneClick | null;
|
|
35
|
+
/** An app on this computer that Connect adds without a sign-in (LOCAL_APPS): its tool, name and one line of how. */
|
|
36
|
+
export declare const localConnectOf: (id: string) => {
|
|
18
37
|
id: string;
|
|
19
38
|
label: string;
|
|
20
|
-
url: string;
|
|
21
39
|
about: string;
|
|
22
40
|
} | null;
|
|
23
|
-
/** Where each service stands in a project: connected
|
|
41
|
+
/** Where each service stands in a project: connected, open to connect, or its name taken by a tool set up another way. `local` connects with no sign-in. */
|
|
24
42
|
export declare function connectionsOf(store: Store, repo: string): {
|
|
25
43
|
id: string;
|
|
26
44
|
label: string;
|
|
27
45
|
about: string;
|
|
28
46
|
state: "connected" | "open" | "taken";
|
|
47
|
+
local?: true;
|
|
29
48
|
}[];
|
|
49
|
+
/**
|
|
50
|
+
* Whether an action only reads: its name has a reading verb, and every other
|
|
51
|
+
* word is a reading verb, a qualifier or something this service lets
|
|
52
|
+
* research read. An allow-list: a word nobody listed withholds the action.
|
|
53
|
+
*/
|
|
54
|
+
export declare function readsOnly(action: string, subjects: readonly string[]): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* What a research step may use of a project's connected services: each
|
|
57
|
+
* signed-in service's read-only actions, as its last test listed them —
|
|
58
|
+
* by server (`reads`, for Codex's `enabled_tools`) and named the way Claude
|
|
59
|
+
* allows them (`mcp__<service>__<action>`). `local` is the loopback host of
|
|
60
|
+
* each app on this computer it reads (its proxy lets only those through). A service not connected, without
|
|
61
|
+
* read-only actions, or left out of this run (`launched`) gives nothing.
|
|
62
|
+
*/
|
|
63
|
+
export type ResearchTools = {
|
|
64
|
+
services: {
|
|
65
|
+
id: string;
|
|
66
|
+
label: string;
|
|
67
|
+
}[];
|
|
68
|
+
allowed: string[];
|
|
69
|
+
reads: Record<string, string[]>;
|
|
70
|
+
local: string[];
|
|
71
|
+
};
|
|
72
|
+
export declare function researchToolsOf(tools: readonly ProjectTool[], launched?: ReadonlySet<string> | null): ResearchTools;
|
|
30
73
|
/** Where a service sends the person back after they sign in. */
|
|
31
74
|
export declare const CONNECT_CALLBACK = "/settings/tools/connected";
|
|
32
75
|
type Fetch = typeof fetch;
|
package/dist/mcp-connect.js
CHANGED
|
@@ -13,9 +13,20 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createHash, randomBytes } from "node:crypto";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import {
|
|
16
|
+
import { basename } from "node:path";
|
|
17
|
+
import { LOCAL_APPS, addToolTo, catalogTool, localAppOf, projectToolsOf, readToolSecrets, setToolSecrets, testToolOf } from "./project-tools.js";
|
|
17
18
|
import { envValue } from "./names.js";
|
|
18
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Services that connect this way: their servers speak streamable HTTP and
|
|
21
|
+
* advertise registering clients on the spot. Advertising isn't accepting:
|
|
22
|
+
* Figma's server advertises it, but its registration answers 403 to any app
|
|
23
|
+
* Figma hasn't approved, so Figma connects through its desktop app instead
|
|
24
|
+
* (LOCAL_APPS). A service is listed only when its registration accepts
|
|
25
|
+
* Toolroll; mcp-connect.test.ts replays each one's answer (no live calls).
|
|
26
|
+
*
|
|
27
|
+
* Signed in end to end, live: Mobbin. The rest register in replayed checks
|
|
28
|
+
* only, until someone signs in to each for real.
|
|
29
|
+
*/
|
|
19
30
|
export const ONE_CLICK = [
|
|
20
31
|
{ id: "stripe", label: "Stripe", url: "https://mcp.stripe.com/", about: "Payments, customers, refunds and invoices." },
|
|
21
32
|
{ id: "notion", label: "Notion", url: "https://mcp.notion.com/mcp", about: "Pages and databases: search, read and write." },
|
|
@@ -33,6 +44,13 @@ export const ONE_CLICK = [
|
|
|
33
44
|
{ id: "canva", label: "Canva", url: "https://mcp.canva.com/mcp", about: "Designs: search, create and export." },
|
|
34
45
|
{ id: "vercel", label: "Vercel", url: "https://mcp.vercel.com/", about: "Projects, deployments and logs." },
|
|
35
46
|
{ id: "cloudflare", label: "Cloudflare", url: "https://mcp.cloudflare.com/mcp", about: "Workers, DNS and your Cloudflare account." },
|
|
47
|
+
// Mobbin's sign-in is its Supabase auth server, on another origin: its protected-resource metadata names it.
|
|
48
|
+
{ id: "mobbin", label: "Mobbin", url: "https://api.mobbin.com/mcp", about: "Real app screens and flows to learn from.",
|
|
49
|
+
reads: ["screen", "flow", "app", "site", "element", "pattern", "ui", "ios", "android", "web"] },
|
|
50
|
+
{ id: "posthog", label: "PostHog", url: "https://mcp.posthog.com/mcp", about: "Product analytics, funnels and events.",
|
|
51
|
+
reads: ["insight", "run", "event", "definition", "property", "properties"] },
|
|
52
|
+
{ id: "betterstack", label: "Better Stack", url: "https://mcp.betterstack.com", about: "Uptime checks and incidents.",
|
|
53
|
+
reads: ["uptime", "monitor", "incident", "availability", "response", "time"] },
|
|
36
54
|
];
|
|
37
55
|
const loopback = (value) => { try {
|
|
38
56
|
const url = new URL(value);
|
|
@@ -51,17 +69,72 @@ export function oneClickServices(environment = process.env) {
|
|
|
51
69
|
const [id = "", label = "", url = ""] = (envValue(environment, "TEST_CONNECT") ?? "").split("|");
|
|
52
70
|
if (!/^[a-z0-9-]{1,40}$/.test(id) || label === "" || !loopback(url))
|
|
53
71
|
return ONE_CLICK;
|
|
54
|
-
const
|
|
72
|
+
const known = ONE_CLICK.find(one => one.id === id);
|
|
73
|
+
const standIn = { id, label, url, about: known?.about ?? "A service on this computer.", ...(known?.reads === undefined ? {} : { reads: known.reads }) };
|
|
55
74
|
return ONE_CLICK.some(one => one.id === id) ? ONE_CLICK.map(one => one.id === id ? standIn : one) : [...ONE_CLICK, standIn];
|
|
56
75
|
}
|
|
57
76
|
export const oneClickOf = (id) => oneClickServices().find(one => one.id === id) ?? null;
|
|
58
|
-
/**
|
|
77
|
+
/** What to use when a service won't let Toolroll sign in by itself. */
|
|
78
|
+
const INSTEAD = { figma: "Connect Figma (desktop app) instead." };
|
|
79
|
+
/** An app on this computer that Connect adds without a sign-in (LOCAL_APPS): its tool, name and one line of how. */
|
|
80
|
+
export const localConnectOf = (id) => {
|
|
81
|
+
const app = LOCAL_APPS.find(one => one.tool === id), tool = catalogTool(id);
|
|
82
|
+
return app === undefined || tool === null ? null : { id, label: tool.label, about: app.connect };
|
|
83
|
+
};
|
|
84
|
+
/** Where each service stands in a project: connected, open to connect, or its name taken by a tool set up another way. `local` connects with no sign-in. */
|
|
59
85
|
export function connectionsOf(store, repo) {
|
|
60
86
|
const tools = projectToolsOf(store, repo);
|
|
61
|
-
|
|
87
|
+
const signIn = oneClickServices().map(service => {
|
|
62
88
|
const had = tools.find(one => one.name === service.id);
|
|
63
89
|
return { id: service.id, label: service.label, about: service.about, state: had === undefined ? "open" : had.spec.url === service.url && had.spec.bearer === ACCESS ? "connected" : "taken" };
|
|
64
90
|
});
|
|
91
|
+
const local = LOCAL_APPS.flatMap(app => {
|
|
92
|
+
const one = localConnectOf(app.tool);
|
|
93
|
+
if (one === null)
|
|
94
|
+
return [];
|
|
95
|
+
const had = tools.find(tool => tool.name === one.id);
|
|
96
|
+
return [{ ...one, state: had === undefined ? "open" : localAppOf(had.spec) !== null ? "connected" : "taken", local: true }];
|
|
97
|
+
});
|
|
98
|
+
return [...signIn, ...local];
|
|
99
|
+
}
|
|
100
|
+
/** The verbs that only read. An action named by anything else is withheld from research. */
|
|
101
|
+
const READING = new Set(["list", "get", "search", "find", "read", "query", "describe", "fetch", "view", "export-image"]);
|
|
102
|
+
/** Words that only qualify what is read ("insights-get-all", "get_by_id"). */
|
|
103
|
+
const QUALIFIERS = new Set(["all", "by", "id", "ids"]);
|
|
104
|
+
/**
|
|
105
|
+
* Whether an action only reads: its name has a reading verb, and every other
|
|
106
|
+
* word is a reading verb, a qualifier or something this service lets
|
|
107
|
+
* research read. An allow-list: a word nobody listed withholds the action.
|
|
108
|
+
*/
|
|
109
|
+
export function readsOnly(action, subjects) {
|
|
110
|
+
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(action))
|
|
111
|
+
return false;
|
|
112
|
+
const words = action.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(word => word !== "")
|
|
113
|
+
.join(" ").replace(/\bexport images?\b/g, "export-image").split(" ");
|
|
114
|
+
const subject = (word) => subjects.includes(word) || subjects.includes(word.replace(/s$/, ""));
|
|
115
|
+
return words.some(word => READING.has(word)) && words.every(word => READING.has(word) || QUALIFIERS.has(word) || subject(word));
|
|
116
|
+
}
|
|
117
|
+
export function researchToolsOf(tools, launched = null) {
|
|
118
|
+
const research = { services: [], allowed: [], reads: {}, local: [] };
|
|
119
|
+
// Signed-in services, then apps on this computer (each exactly as listed: its own address, no key).
|
|
120
|
+
const readable = [
|
|
121
|
+
...oneClickServices().map(service => ({ id: service.id, label: service.label, reads: service.reads, is: (spec) => spec.url === service.url && spec.bearer === ACCESS })),
|
|
122
|
+
...LOCAL_APPS.map(app => ({ id: app.tool, label: catalogTool(app.tool)?.label ?? app.tool, reads: app.reads, is: (spec) => localAppOf(spec) === app })),
|
|
123
|
+
];
|
|
124
|
+
for (const service of readable) {
|
|
125
|
+
const tool = tools.find(one => one.name === service.id);
|
|
126
|
+
if (service.reads === undefined || tool === undefined || !service.is(tool.spec) || (launched !== null && !launched.has(tool.name)))
|
|
127
|
+
continue;
|
|
128
|
+
const reads = (tool.lastTest?.ok ? tool.lastTest.tools : []).filter(action => readsOnly(action, service.reads));
|
|
129
|
+
if (reads.length === 0)
|
|
130
|
+
continue;
|
|
131
|
+
research.services.push({ id: service.id, label: service.label });
|
|
132
|
+
research.allowed.push(...reads.map(action => `mcp__${service.id}__${action}`));
|
|
133
|
+
research.reads[service.id] = reads;
|
|
134
|
+
if (localAppOf(tool.spec) !== null)
|
|
135
|
+
research.local.push(new URL(tool.spec.url).host);
|
|
136
|
+
}
|
|
137
|
+
return research;
|
|
65
138
|
}
|
|
66
139
|
/** The secrets a connected tool keeps: the bearer the MCP server takes, and what refreshing it needs. */
|
|
67
140
|
const ACCESS = "OAUTH_ACCESS_TOKEN", REFRESH = "OAUTH_REFRESH_TOKEN", CLIENT = "OAUTH_CLIENT_ID", CLIENT_SECRET = "OAUTH_CLIENT_SECRET", TOKEN_URL = "OAUTH_TOKEN_URL", EXPIRES = "OAUTH_EXPIRES_AT", RESOURCE = "OAUTH_RESOURCE";
|
|
@@ -110,6 +183,7 @@ export async function discoverSignIn(mcpUrl, fetcher = fetch) {
|
|
|
110
183
|
const path = issuer.pathname === "/" ? "" : issuer.pathname.replace(/\/$/, "");
|
|
111
184
|
const meta = await json(fetcher, `${issuer.origin}/.well-known/oauth-authorization-server${path}`)
|
|
112
185
|
?? await json(fetcher, `${issuer.origin}/.well-known/oauth-authorization-server`)
|
|
186
|
+
?? (path === "" ? null : await json(fetcher, `${issuer.origin}${path}/.well-known/openid-configuration`))
|
|
113
187
|
?? await json(fetcher, `${issuer.origin}/.well-known/openid-configuration`);
|
|
114
188
|
if (meta === null || !https(meta["authorization_endpoint"]) || !https(meta["token_endpoint"]) || !https(meta["registration_endpoint"]))
|
|
115
189
|
return null;
|
|
@@ -138,6 +212,9 @@ export async function startConnect(input, fetcher = fetch, now = Date.now()) {
|
|
|
138
212
|
try {
|
|
139
213
|
const registered = await fetcher(server.register, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, signal: AbortSignal.timeout(10_000),
|
|
140
214
|
body: JSON.stringify({ client_name: "Toolroll", redirect_uris: [redirect], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none" }) });
|
|
215
|
+
// Figma's answer (Oct 2026): it registers only apps it has approved. Said before reading a body that may not be JSON.
|
|
216
|
+
if (registered.status === 401 || registered.status === 403)
|
|
217
|
+
return { ok: false, said: `${service.label} doesn't let other apps sign in this way yet.${INSTEAD[service.id] === undefined ? "" : ` ${INSTEAD[service.id]}`}` };
|
|
141
218
|
const body = await registered.json();
|
|
142
219
|
if (!registered.ok || typeof body.client_id !== "string")
|
|
143
220
|
return { ok: false, said: `${service.label} didn't let Toolroll register (HTTP ${registered.status}).` };
|
|
@@ -208,11 +285,13 @@ export async function finishConnect(store, visit, code, now, options = {}) {
|
|
|
208
285
|
}
|
|
209
286
|
setToolSecrets(visit.repo, spec.name, { [ACCESS]: tokens.access, [CLIENT]: visit.clientId, [TOKEN_URL]: visit.token, [RESOURCE]: visit.resource,
|
|
210
287
|
...(tokens.refresh === null ? {} : { [REFRESH]: tokens.refresh }), ...(tokens.expires === null ? {} : { [EXPIRES]: tokens.expires }), ...(visit.clientSecret === null ? {} : { [CLIENT_SECRET]: visit.clientSecret }) }, home);
|
|
288
|
+
// The success line names the project it went to: the page and chat may be looking at another.
|
|
289
|
+
const to = basename(visit.repo);
|
|
211
290
|
if (options.test === false)
|
|
212
|
-
return { ok: true, said: `${service.label} is connected.` };
|
|
291
|
+
return { ok: true, said: `${service.label} is connected to ${to}.` };
|
|
213
292
|
const tested = await testToolOf(store, visit.repo, spec.name, now, { home, ...(options.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) });
|
|
214
|
-
return tested?.ok ? { ok: true, said: `${service.label} is connected: ${tested.tools.length} action${tested.tools.length === 1 ? "" : "s"}. Let a teammate use it from its page.` }
|
|
215
|
-
: { ok: true, said: `${service.label} is signed in, but its test didn't pass yet: ${tested?.problem ?? "no answer"}.` };
|
|
293
|
+
return tested?.ok ? { ok: true, said: `${service.label} is connected to ${to}: ${tested.tools.length} action${tested.tools.length === 1 ? "" : "s"}. Let a teammate use it from its page.` }
|
|
294
|
+
: { ok: true, said: `${service.label} is signed in for ${to}, but its test didn't pass yet: ${tested?.problem ?? "no answer"}.` };
|
|
216
295
|
}
|
|
217
296
|
/** Keep connected services signed in: any token expiring within ten minutes is refreshed (the worker's pass, and before a teammate's call). */
|
|
218
297
|
export async function refreshConnections(store, repos, now, options = {}) {
|
package/dist/operate.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type IntegrationIo } from "./integrations.js";
|
|
2
2
|
import { type OnboardIo } from "./agent-onboard.js";
|
|
3
3
|
import { type TriggerIo } from "./flow-triggers.js";
|
|
4
|
-
import {
|
|
4
|
+
import type { StepIo } from "./flow-steps.js";
|
|
5
5
|
import type { FetchLike } from "./flow-share.js";
|
|
6
6
|
import { type Store } from "./store.js";
|
|
7
7
|
import { type DispatchAdapter } from "./sync.js";
|
|
@@ -61,6 +61,8 @@ export type OperateOptions = {
|
|
|
61
61
|
flowTriggerIo?: Partial<TriggerIo>;
|
|
62
62
|
/** Injected by tests: how flow check and update steps run commands and reach GitHub and Linear. */
|
|
63
63
|
flowStepIo?: Partial<StepIo>;
|
|
64
|
+
/** Injected by tests: how often a worker's flow passes run beside its builds (default FLOW_EVERY_MS). */
|
|
65
|
+
flowEveryMs?: number;
|
|
64
66
|
/** Injected by tests: how `flows import` fetches a flow file's address. */
|
|
65
67
|
flowFetch?: FetchLike;
|
|
66
68
|
/** Injected by tests: how `integrations` checks reach services (fetch, gh, sign-in checks, mail servers). */
|