toolstile 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
1
+ import { spawn } from "node:child_process";
2
+ import { hostname } from "node:os";
3
+ import { ManualEditError, proxyCommand, readEntry, isProxyEntry, targets, writeEntry } from "./agents.js";
4
+ import { ApiError } from "./api.js";
5
+ import { checklist } from "./prompt.js";
6
+ import { runProxy } from "./proxy.js";
7
+ import { tildify } from "./report.js";
8
+ import { CLIENT_NAMES } from "./sources.js";
9
+ import { loadState, saveState } from "./state.js";
10
+ /** A failure explained to the user; exits 1 without a stack trace. */
11
+ export class UserError extends Error {
12
+ }
13
+ const stateEnv = (ctx) => ({ home: ctx.home, env: ctx.env, platform: ctx.platform });
14
+ const print = (ctx, text = "") => ctx.stdout.write(`${text}\n`);
15
+ const interactive = (ctx) => ctx.stdin.isTTY === true && ctx.stdout.isTTY === true;
16
+ /** HTTPS, except a loopback server for local development. */
17
+ export function normalizeServer(value) {
18
+ let url;
19
+ try {
20
+ url = new URL(value);
21
+ }
22
+ catch {
23
+ throw new UserError(`--server must be a URL, for example https://toolstile.com`);
24
+ }
25
+ const loopback = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])$/.test(url.hostname);
26
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))
27
+ throw new UserError("--server must use https://");
28
+ return url.origin;
29
+ }
30
+ function defaultOpenBrowser(ctx) {
31
+ return (url) => {
32
+ // Over SSH or without a display the URL and code are enough; nothing to open.
33
+ if (ctx.env.SSH_CONNECTION || ctx.env.SSH_TTY)
34
+ return;
35
+ if (ctx.platform === "linux" && !ctx.env.DISPLAY && !ctx.env.WAYLAND_DISPLAY)
36
+ return;
37
+ const [command, args] = ctx.platform === "darwin" ? ["open", [url]] : ctx.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
38
+ try {
39
+ spawn(command, args, { stdio: "ignore", detached: true }).on("error", () => { }).unref();
40
+ }
41
+ catch { }
42
+ };
43
+ }
44
+ /** The name this machine suggests for itself: its hostname, without macOS's `.local`. */
45
+ const machineName = (ctx) => (ctx.hostname ?? hostname)().replace(/\.local$/i, "");
46
+ const formatCode = (code) => (code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code);
47
+ export async function login(ctx, options) {
48
+ const s = ctx.style;
49
+ const state = loadState(stateEnv(ctx));
50
+ const server = normalizeServer(options.server ?? ctx.env.TOOLSTILE_URL ?? state.server);
51
+ const api = ctx.api(server);
52
+ const code = await api.deviceCode();
53
+ // The page fills in the code and this machine's name; the name can be edited there and
54
+ // becomes the device this machine's agents are listed under.
55
+ const link = new URL(code.verification_uri_complete);
56
+ link.searchParams.set("device_name", machineName(ctx));
57
+ print(ctx, `Open this link on any device with a browser and approve the sign-in:`);
58
+ print(ctx);
59
+ print(ctx, ` ${s.bold(link.href)}`);
60
+ print(ctx);
61
+ print(ctx, `Check that the page shows this code: ${s.bold(formatCode(code.user_code))}`);
62
+ print(ctx, s.dim(`Waiting for approval. The code expires in ${Math.round(code.expires_in / 60)} minutes.`));
63
+ if (options.browser)
64
+ (ctx.openBrowser ?? defaultOpenBrowser(ctx))(link.href);
65
+ const sleep = ctx.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
66
+ let interval = Math.max(code.interval, 1) * 1000;
67
+ const deadline = Date.now() + code.expires_in * 1000;
68
+ let token;
69
+ while (!token) {
70
+ if (Date.now() > deadline)
71
+ throw new UserError("The code expired. Run toolstile auth login again.");
72
+ await sleep(interval);
73
+ try {
74
+ token = (await api.deviceToken(code.device_code)).access_token;
75
+ }
76
+ catch (error) {
77
+ if (!(error instanceof ApiError))
78
+ throw error;
79
+ if (error.code === "authorization_pending")
80
+ continue;
81
+ if (error.code === "slow_down") {
82
+ interval += 5000;
83
+ continue;
84
+ }
85
+ if (error.code === "access_denied")
86
+ throw new UserError("Sign-in was denied in the browser.");
87
+ if (error.code === "expired_token")
88
+ throw new UserError("The code expired. Run toolstile auth login again.");
89
+ throw error;
90
+ }
91
+ }
92
+ const account = await api.account(token);
93
+ const previous = state.session;
94
+ // The old session is ended where it was issued, never sent to the new server.
95
+ if (previous)
96
+ await ctx.api(previous.server ?? state.server).signOut(previous.token).catch(() => undefined);
97
+ if (server !== state.server || (previous && previous.email !== account.email)) {
98
+ // Another account's workspace and device do not apply; enabled agents keep their own access.
99
+ delete state.workspace;
100
+ delete state.device;
101
+ }
102
+ state.server = server;
103
+ state.session = { token, email: account.email, expiresAt: account.expiresAt, server };
104
+ // The device named on the approval page. Agents still on an older device move to it at
105
+ // the next `mcp setup`.
106
+ if (account.device)
107
+ state.device = account.device;
108
+ saveState(state, stateEnv(ctx));
109
+ print(ctx);
110
+ print(ctx, `Signed in as ${s.bold(account.email)}${account.device ? ` on ${s.bold(account.device.label)}` : ""}. Next: ${s.bold("toolstile mcp setup")}`);
111
+ return 0;
112
+ }
113
+ export async function logout(ctx) {
114
+ const state = loadState(stateEnv(ctx));
115
+ if (!state.session) {
116
+ print(ctx, "Not signed in.");
117
+ return 0;
118
+ }
119
+ let reached = true;
120
+ await ctx
121
+ .api(state.session.server ?? state.server)
122
+ .signOut(state.session.token)
123
+ .catch((error) => {
124
+ // 401 means the session had already ended; anything else is worth saying.
125
+ if (!(error instanceof ApiError && error.status === 401))
126
+ reached = false;
127
+ });
128
+ delete state.session;
129
+ saveState(state, stateEnv(ctx));
130
+ print(ctx, reached ? "Signed out." : "Signed out on this machine. Toolstile could not be reached, so the session ends when it expires.");
131
+ const enabled = Object.keys(state.agents).length;
132
+ if (enabled)
133
+ print(ctx, ctx.style.dim(`${enabled === 1 ? "The agent" : `The ${enabled} agents`} you enabled keep working. To turn ${enabled === 1 ? "it" : "them"} off, run toolstile mcp setup while signed in, or delete this device in the Toolstile dashboard.`));
134
+ return 0;
135
+ }
136
+ function parseClients(list, flag) {
137
+ if (!list)
138
+ return [];
139
+ const ids = Object.keys(CLIENT_NAMES);
140
+ return list.split(",").map((raw) => {
141
+ const value = raw.trim().toLowerCase();
142
+ const match = ids.find((id) => id === value || CLIENT_NAMES[id].toLowerCase().replace(/\s+/g, "-") === value);
143
+ if (!match)
144
+ throw new UserError(`${flag}: unknown agent "${raw.trim()}". Use ${ids.join(", ")}.`);
145
+ return match;
146
+ });
147
+ }
148
+ async function chooseWorkspace(ctx, state, workspaces, wanted) {
149
+ if (wanted) {
150
+ const value = wanted.toLowerCase();
151
+ const match = workspaces.find((w) => w.slug === value || w.id === wanted || w.name.toLowerCase() === value);
152
+ if (!match)
153
+ throw new UserError(`No workspace "${wanted}". Yours: ${workspaces.map((w) => w.slug).join(", ")}.`);
154
+ return match;
155
+ }
156
+ const current = workspaces.find((w) => w.id === state.workspace?.id) ?? workspaces.find((w) => w.personal) ?? workspaces[0];
157
+ if (!current)
158
+ throw new UserError("Your account has no workspace yet. Open Toolstile in a browser once, then try again.");
159
+ if (workspaces.length === 1 || !interactive(ctx))
160
+ return current;
161
+ const picked = await checklist("Which workspace should this machine's agents use?", workspaces.map((w) => ({ label: w.name, hint: w.personal ? "personal" : w.slug, checked: w.id === current.id })), ctx.stdin, ctx.stdout, { single: true });
162
+ if (!picked)
163
+ throw new UserError("Cancelled; nothing changed.");
164
+ return workspaces[picked.indexOf(true)];
165
+ }
166
+ export async function setup(ctx, options) {
167
+ const s = ctx.style;
168
+ const state = loadState(stateEnv(ctx));
169
+ const session = state.session;
170
+ if (!session)
171
+ throw new UserError("Sign in first: toolstile auth login");
172
+ const api = ctx.api(session.server ?? state.server);
173
+ const account = await api.account(session.token).catch((error) => {
174
+ if (error instanceof ApiError && error.status === 401)
175
+ throw new UserError("Your sign-in has expired. Run toolstile auth login again.");
176
+ throw error;
177
+ });
178
+ const enable = parseClients(options.enable, "--enable");
179
+ const disable = parseClients(options.disable, "--disable");
180
+ const both = enable.find((c) => disable.includes(c));
181
+ if (both)
182
+ throw new UserError(`${CLIENT_NAMES[both]} is in both --enable and --disable.`);
183
+ const workspace = await chooseWorkspace(ctx, state, account.workspaces, options.workspace);
184
+ state.workspace = { id: workspace.id, slug: workspace.slug, name: workspace.name };
185
+ saveState(state, stateEnv(ctx));
186
+ /** Created (or renamed) only when something needs it, so listing changes nothing. */
187
+ let device;
188
+ const ensureDevice = async () => {
189
+ device ??= await api.device(session.token, {
190
+ workspaceId: workspace.id,
191
+ id: state.device?.id,
192
+ label: options.deviceName ?? state.device?.label ?? machineName(ctx),
193
+ rename: options.deviceName !== undefined,
194
+ });
195
+ state.device = device;
196
+ saveState(state, stateEnv(ctx));
197
+ return device;
198
+ };
199
+ if (options.deviceName)
200
+ await ensureDevice();
201
+ // Agents deleted from the dashboard, or left in another workspace, are no longer enabled.
202
+ const remote = new Set(state.device ? (await api.agents(session.token, workspace.id, state.device.id)).agents.map((a) => a.id) : []);
203
+ const live = (client) => {
204
+ const link = state.agents[client];
205
+ return !!link && link.server === state.server && link.workspaceId === workspace.id && remote.has(link.id);
206
+ };
207
+ /** Removes an agent where it was created; links from another server are just dropped. */
208
+ const removeRemote = (link) => link.server === state.server
209
+ ? api.removeAgent(session.token, link.workspaceId, link.id).catch((error) => {
210
+ if (!(error instanceof ApiError && (error.status === 404 || error.status === 403)))
211
+ throw error;
212
+ })
213
+ : Promise.resolve();
214
+ const deviceLabel = state.device?.label ?? machineName(ctx);
215
+ const all = targets(ctx);
216
+ const shown = all.filter((t) => t.detected || live(t.client) || state.agents[t.client] || enable.includes(t.client));
217
+ if (!shown.length) {
218
+ print(ctx, "No supported agents found on this machine. Supported: " + all.map((t) => t.name).join(", ") + ".");
219
+ return 0;
220
+ }
221
+ let wanted;
222
+ if (enable.length || disable.length) {
223
+ wanted = new Set(all.filter((t) => live(t.client)).map((t) => t.client));
224
+ for (const c of enable)
225
+ wanted.add(c);
226
+ for (const c of disable)
227
+ wanted.delete(c);
228
+ }
229
+ else if (interactive(ctx)) {
230
+ print(ctx, `Signed in as ${account.email} · workspace ${s.bold(workspace.name)} · device ${s.bold(deviceLabel)}`);
231
+ const picked = await checklist("Which agents on this machine should use Toolstile?", shown.map((t) => ({
232
+ label: t.name,
233
+ hint: t.detected ? tildify(t.path, ctx.home) : "not found on this machine",
234
+ checked: live(t.client),
235
+ })), ctx.stdin, ctx.stdout);
236
+ if (!picked) {
237
+ print(ctx, "Cancelled; nothing changed.");
238
+ return 0;
239
+ }
240
+ wanted = new Set(shown.filter((_, i) => picked[i]).map((t) => t.client));
241
+ }
242
+ else {
243
+ print(ctx, `Workspace ${workspace.name} · device ${deviceLabel}`);
244
+ for (const t of shown)
245
+ print(ctx, ` ${live(t.client) ? "enabled " : "disabled"} ${t.client.padEnd(14)} ${t.name}`);
246
+ print(ctx);
247
+ print(ctx, "Choose agents with --enable and --disable, for example: toolstile mcp setup --enable claude-code,codex");
248
+ return 0;
249
+ }
250
+ const manual = [];
251
+ const changes = [];
252
+ const replaced = [];
253
+ for (const target of all) {
254
+ const want = wanted.has(target.client);
255
+ const link = state.agents[target.client];
256
+ if (want) {
257
+ if (!live(target.client)) {
258
+ if (link)
259
+ await removeRemote(link).catch(() => undefined);
260
+ const { id: deviceId } = await ensureDevice();
261
+ const created = await api.addAgent(session.token, { workspaceId: workspace.id, deviceId, label: target.name });
262
+ state.agents[target.client] = {
263
+ id: created.id,
264
+ workspaceId: workspace.id,
265
+ server: state.server,
266
+ label: created.label,
267
+ token: created.token,
268
+ };
269
+ // Saved before touching the agent's config, so a token is never lost.
270
+ saveState(state, stateEnv(ctx));
271
+ }
272
+ try {
273
+ const result = writeEntry(target, proxyCommand(target.client, ctx.version, ctx.platform));
274
+ if (!live(target.client) || result !== "unchanged")
275
+ changes.push(`${s.bold("+")} ${target.name} ${s.dim(tildify(target.path, ctx.home))}`);
276
+ if (result === "replaced")
277
+ replaced.push(target.name);
278
+ }
279
+ catch (error) {
280
+ if (!(error instanceof ManualEditError))
281
+ throw error;
282
+ manual.push({ target, error, enable: true });
283
+ }
284
+ }
285
+ else if (link || isProxyEntry(readEntry(target))) {
286
+ try {
287
+ writeEntry(target, null);
288
+ }
289
+ catch (error) {
290
+ if (!(error instanceof ManualEditError))
291
+ throw error;
292
+ manual.push({ target, error, enable: false });
293
+ }
294
+ if (link) {
295
+ await removeRemote(link);
296
+ delete state.agents[target.client];
297
+ saveState(state, stateEnv(ctx));
298
+ }
299
+ changes.push(`${s.bold("-")} ${target.name}`);
300
+ }
301
+ }
302
+ print(ctx);
303
+ if (changes.length) {
304
+ for (const line of changes)
305
+ print(ctx, line);
306
+ print(ctx);
307
+ print(ctx, "Restart agents that are running to pick up the change.");
308
+ }
309
+ else
310
+ print(ctx, "Nothing to change.");
311
+ if (replaced.length) {
312
+ print(ctx);
313
+ print(ctx, `Replaced an existing "toolstile" server in ${replaced.join(", ")}. If it signed in directly, remove that older agent under Devices & agents in the dashboard.`);
314
+ }
315
+ for (const { target, error, enable } of manual) {
316
+ print(ctx);
317
+ print(ctx, `${target.name}: ${tildify(target.path, ctx.home)} was not changed because ${error.message}.`);
318
+ if (enable) {
319
+ print(ctx, "Add this entry yourself:");
320
+ print(ctx, error.snippet.replace(/^/gm, " "));
321
+ }
322
+ else
323
+ print(ctx, `Remove its "toolstile" server entry yourself.`);
324
+ }
325
+ if ([...wanted].length) {
326
+ print(ctx);
327
+ print(ctx, `Choose which MCP servers they can use: ${state.server}/app/${workspace.slug}/access`);
328
+ }
329
+ return manual.length ? 1 : 0;
330
+ }
331
+ export async function proxy(ctx, agent) {
332
+ const [client] = parseClients(agent, "--agent");
333
+ if (!client)
334
+ throw new UserError("--agent is required");
335
+ const state = loadState(stateEnv(ctx));
336
+ const link = state.agents[client];
337
+ if (!link)
338
+ throw new UserError(`${CLIENT_NAMES[client]} is not enabled on this machine. Run toolstile mcp setup.`);
339
+ await runProxy({
340
+ // The token goes only to the server that issued it, whatever the environment says.
341
+ endpoint: `${link.server}/mcp`,
342
+ token: link.token,
343
+ input: ctx.stdin,
344
+ output: ctx.stdout,
345
+ log: (message) => ctx.stderr.write(`toolstile: ${message}\n`),
346
+ });
347
+ return 0;
348
+ }
@@ -0,0 +1,14 @@
1
+ import type { ReadStream, WriteStream } from "node:tty";
2
+ export interface Choice {
3
+ label: string;
4
+ hint?: string;
5
+ checked: boolean;
6
+ }
7
+ /**
8
+ * Terminal checklist: arrows (or j/k) move, space toggles, a toggles all, enter saves.
9
+ * Every line is fitted to the terminal width: a wrapped line takes two rows, and the redraw
10
+ * would then move up too few rows and leave copies of the title behind.
11
+ */
12
+ export declare function checklist(title: string, choices: Choice[], input: ReadStream, output: WriteStream, options?: {
13
+ single?: boolean;
14
+ }): Promise<boolean[] | null>;
package/dist/prompt.js ADDED
@@ -0,0 +1,68 @@
1
+ import { emitKeypressEvents } from "node:readline";
2
+ /** Shortens text to `width` columns, keeping its end (the file name of a path). */
3
+ function fitStart(text, width) {
4
+ return text.length <= width ? text : width < 2 ? "" : `…${text.slice(text.length - width + 1)}`;
5
+ }
6
+ const fitEnd = (text, width) => text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
7
+ /**
8
+ * Terminal checklist: arrows (or j/k) move, space toggles, a toggles all, enter saves.
9
+ * Every line is fitted to the terminal width: a wrapped line takes two rows, and the redraw
10
+ * would then move up too few rows and leave copies of the title behind.
11
+ */
12
+ export function checklist(title, choices, input, output, options = {}) {
13
+ const state = choices.map((c) => c.checked);
14
+ let cursor = Math.max(0, options.single ? state.indexOf(true) : 0);
15
+ let drawn = 0;
16
+ const help = options.single ? "↑/↓ move · enter select · esc cancel" : "↑/↓ move · space toggle · a all · enter save · esc cancel";
17
+ const render = () => {
18
+ if (drawn)
19
+ output.write(`\x1b[${drawn}A\x1b[0J`);
20
+ // A spare column for glyphs some terminals draw double-width.
21
+ const width = Math.max(20, (output.columns || 80) - 2);
22
+ const lines = [
23
+ fitEnd(title, width),
24
+ ...choices.map((c, i) => {
25
+ const mark = options.single ? (i === cursor ? "●" : "○") : state[i] ? "◉" : "○";
26
+ const pointer = i === cursor ? "›" : " ";
27
+ const text = fitEnd(`${pointer} ${mark} ${c.label}`, width);
28
+ const room = width - text.length - 2;
29
+ const hint = c.hint && room >= 8 ? ` \x1b[2m${fitStart(c.hint, room)}\x1b[22m` : "";
30
+ return `${text}${hint}`;
31
+ }),
32
+ `\x1b[2m${fitEnd(help, width)}\x1b[22m`,
33
+ ];
34
+ output.write(`${lines.join("\n")}\n`);
35
+ drawn = lines.length;
36
+ };
37
+ return new Promise((resolve) => {
38
+ emitKeypressEvents(input);
39
+ const raw = input.isRaw;
40
+ input.setRawMode(true);
41
+ input.resume();
42
+ output.write("\x1b[?25l");
43
+ const finish = (value) => {
44
+ input.off("keypress", onKey);
45
+ input.setRawMode(raw);
46
+ input.pause();
47
+ output.write("\x1b[?25h");
48
+ resolve(value);
49
+ };
50
+ const onKey = (text, key = {}) => {
51
+ if ((key.ctrl && key.name === "c") || key.name === "escape")
52
+ return finish(null);
53
+ if (key.name === "up" || text === "k")
54
+ cursor = (cursor - 1 + choices.length) % choices.length;
55
+ else if (key.name === "down" || text === "j")
56
+ cursor = (cursor + 1) % choices.length;
57
+ else if (!options.single && (key.name === "space" || text === " "))
58
+ state[cursor] = !state[cursor];
59
+ else if (!options.single && text === "a")
60
+ state.fill(!state.every(Boolean));
61
+ else if (key.name === "return" || key.name === "enter")
62
+ return finish(options.single ? choices.map((_, i) => i === cursor) : state);
63
+ render();
64
+ };
65
+ input.on("keypress", onKey);
66
+ render();
67
+ });
68
+ }
@@ -0,0 +1,15 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ /**
3
+ * Bridges an agent's stdio MCP connection to the Toolstile gateway's Streamable HTTP endpoint,
4
+ * so the agent's config holds a command instead of a credential. Messages pass through
5
+ * unchanged; only the transport differs.
6
+ */
7
+ export interface ProxyOptions {
8
+ endpoint: string;
9
+ token: string;
10
+ input: Readable;
11
+ output: Writable;
12
+ log: (message: string) => void;
13
+ fetch?: typeof fetch;
14
+ }
15
+ export declare function runProxy(options: ProxyOptions): Promise<void>;
package/dist/proxy.js ADDED
@@ -0,0 +1,235 @@
1
+ import { createInterface } from "node:readline";
2
+ /** Yields each SSE event's data and id. */
3
+ async function* events(body) {
4
+ const decoder = new TextDecoder();
5
+ let buffer = "";
6
+ let data = [];
7
+ let id;
8
+ for await (const chunk of body) {
9
+ buffer += decoder.decode(chunk, { stream: true });
10
+ let newline;
11
+ while ((newline = buffer.search(/\r?\n/)) !== -1) {
12
+ const line = buffer.slice(0, newline);
13
+ buffer = buffer.slice(buffer[newline] === "\r" ? newline + 2 : newline + 1);
14
+ if (line === "") {
15
+ if (data.length)
16
+ yield { data: data.join("\n"), id };
17
+ data = [];
18
+ continue;
19
+ }
20
+ if (line.startsWith(":"))
21
+ continue;
22
+ const colon = line.indexOf(":");
23
+ const field = colon === -1 ? line : line.slice(0, colon);
24
+ const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
25
+ if (field === "data")
26
+ data.push(value);
27
+ else if (field === "id")
28
+ id = value;
29
+ }
30
+ }
31
+ if (data.length)
32
+ yield { data: data.join("\n"), id };
33
+ }
34
+ /** How long in-flight requests may finish after the agent closes stdin. */
35
+ const CLOSE_GRACE_MS = 3000;
36
+ export function runProxy(options) {
37
+ const fetcher = options.fetch ?? fetch;
38
+ let sessionId;
39
+ let protocolVersion;
40
+ /** The agent's own `initialize`, replayed if the gateway forgets the session. */
41
+ let opening;
42
+ let listening = false;
43
+ let closed = false;
44
+ /** Aborts every request and the listener's waits once the agent has gone. */
45
+ const shutdown = new AbortController();
46
+ const pending = new Set();
47
+ /** Later messages wait for `initialize`, so they carry the session it opens. */
48
+ let initialized = Promise.resolve();
49
+ const write = (message) => {
50
+ if (!options.output.writableEnded)
51
+ options.output.write(`${JSON.stringify(message)}\n`);
52
+ };
53
+ const headers = (extra = {}) => ({
54
+ Authorization: `Bearer ${options.token}`,
55
+ ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
56
+ ...(protocolVersion ? { "Mcp-Protocol-Version": protocolVersion } : {}),
57
+ ...extra,
58
+ });
59
+ /** Never follow a redirect: the token is only for the gateway endpoint. */
60
+ const request = (init) => fetcher(options.endpoint, { ...init, redirect: "error", signal: shutdown.signal });
61
+ const post = (body) => request({
62
+ method: "POST",
63
+ headers: headers({ "Content-Type": "application/json", Accept: "application/json, text/event-stream" }),
64
+ body,
65
+ });
66
+ const explain = (status, text) => status === 401
67
+ ? "Toolstile no longer accepts this agent's access (removed from the dashboard?). Run `toolstile mcp setup` to enable it again."
68
+ : status === 403
69
+ ? `Toolstile refused this agent: ${text || "access denied"}`
70
+ : status === 429
71
+ ? "Toolstile is rate limiting this agent; try again shortly."
72
+ : `Toolstile returned ${status}${text ? `: ${text}` : ""}`;
73
+ const deliver = (message, answered) => {
74
+ if (message.id !== undefined && message.result && typeof message.result === "object") {
75
+ const version = message.result.protocolVersion;
76
+ if (typeof version === "string")
77
+ protocolVersion = version;
78
+ }
79
+ if (message.method === undefined && message.id !== undefined)
80
+ answered?.add(message.id);
81
+ write(message);
82
+ };
83
+ async function readBody(response, answered, quiet = false) {
84
+ const type = response.headers.get("content-type") ?? "";
85
+ const take = (message) => (quiet ? answered.add(message.id) : deliver(message, answered));
86
+ if (!response.body)
87
+ return;
88
+ if (type.includes("text/event-stream")) {
89
+ for await (const event of events(response.body)) {
90
+ try {
91
+ take(JSON.parse(event.data));
92
+ }
93
+ catch {
94
+ options.log("Ignored a malformed message from Toolstile.");
95
+ }
96
+ }
97
+ return;
98
+ }
99
+ if (type.includes("application/json")) {
100
+ const body = (await response.json());
101
+ for (const message of Array.isArray(body) ? body : [body])
102
+ take(message);
103
+ }
104
+ }
105
+ const wait = (ms) => new Promise((resolve) => {
106
+ const timer = setTimeout(resolve, ms);
107
+ shutdown.signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
108
+ });
109
+ /** Server-initiated messages (list changes, approvals) arrive on a long-lived GET stream. */
110
+ async function listen() {
111
+ if (listening || !sessionId)
112
+ return;
113
+ listening = true;
114
+ let lastEventId;
115
+ for (let attempt = 0; !closed && attempt < 8; attempt++) {
116
+ try {
117
+ const response = await request({
118
+ method: "GET",
119
+ headers: headers({ Accept: "text/event-stream", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) }),
120
+ });
121
+ // 405: this server offers no stream; that is allowed.
122
+ if (response.status === 405 || !response.ok || !response.body)
123
+ break;
124
+ attempt = 0;
125
+ for await (const event of events(response.body)) {
126
+ if (event.id)
127
+ lastEventId = event.id;
128
+ try {
129
+ deliver(JSON.parse(event.data));
130
+ }
131
+ catch { }
132
+ }
133
+ }
134
+ catch {
135
+ if (closed)
136
+ break;
137
+ }
138
+ await wait(Math.min(30_000, 1000 * 2 ** attempt));
139
+ }
140
+ listening = false;
141
+ }
142
+ /**
143
+ * The gateway answers 404 for a session it no longer has (for example after a restart).
144
+ * Open a new one with the agent's original `initialize`, without showing its reply again.
145
+ */
146
+ async function reopen() {
147
+ if (!opening)
148
+ return false;
149
+ sessionId = undefined;
150
+ const response = await post(opening);
151
+ sessionId = response.headers.get("mcp-session-id") ?? undefined;
152
+ if (!response.ok)
153
+ return false;
154
+ await readBody(response, new Set(), true);
155
+ await post(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })).then((r) => r.body?.cancel());
156
+ void listen();
157
+ return true;
158
+ }
159
+ async function send(line) {
160
+ let messages;
161
+ try {
162
+ const parsed = JSON.parse(line);
163
+ messages = Array.isArray(parsed) ? parsed : [parsed];
164
+ }
165
+ catch {
166
+ write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
167
+ return;
168
+ }
169
+ const requests = messages.filter((m) => m.method !== undefined && m.id !== undefined);
170
+ const answered = new Set();
171
+ const fail = (message) => {
172
+ for (const request of requests)
173
+ if (!answered.has(request.id))
174
+ write({ jsonrpc: "2.0", id: request.id, error: { code: -32000, message } });
175
+ };
176
+ if (messages.some((m) => m.method === "initialize"))
177
+ opening = line;
178
+ try {
179
+ let response = await post(line);
180
+ if (response.status === 404 && sessionId && !messages.some((m) => m.method === "initialize")) {
181
+ await response.body?.cancel();
182
+ if (await reopen())
183
+ response = await post(line);
184
+ }
185
+ const session = response.headers.get("mcp-session-id");
186
+ if (session)
187
+ sessionId = session;
188
+ if (!response.ok) {
189
+ const text = (await response.text().catch(() => "")).trim().slice(0, 300);
190
+ const message = explain(response.status, text);
191
+ options.log(message);
192
+ fail(message);
193
+ return;
194
+ }
195
+ await readBody(response, answered);
196
+ if (messages.some((m) => m.method === "notifications/initialized"))
197
+ void listen();
198
+ // A stream that ended early must still answer every request, or the agent waits forever.
199
+ fail("Toolstile closed the connection before answering. Try again.");
200
+ }
201
+ catch (error) {
202
+ if (closed)
203
+ return fail("The agent closed the connection.");
204
+ const message = `Could not reach Toolstile: ${error.message}`;
205
+ options.log(message);
206
+ fail(message);
207
+ }
208
+ }
209
+ return new Promise((resolve) => {
210
+ const lines = createInterface({ input: options.input, crlfDelay: Number.POSITIVE_INFINITY });
211
+ lines.on("line", (line) => {
212
+ if (!line.trim())
213
+ return;
214
+ const opens = /"method"\s*:\s*"initialize"/.test(line);
215
+ const task = (opens ? send(line) : initialized.then(() => send(line))).finally(() => pending.delete(task));
216
+ if (opens)
217
+ initialized = task;
218
+ pending.add(task);
219
+ });
220
+ lines.on("close", async () => {
221
+ // Let replies already on their way finish, but never hang on a long call or stream.
222
+ const grace = setTimeout(() => {
223
+ closed = true;
224
+ shutdown.abort();
225
+ }, CLOSE_GRACE_MS);
226
+ await Promise.allSettled([...pending]);
227
+ clearTimeout(grace);
228
+ closed = true;
229
+ shutdown.abort();
230
+ if (sessionId)
231
+ await fetcher(options.endpoint, { method: "DELETE", headers: headers(), redirect: "error" }).catch(() => undefined);
232
+ resolve();
233
+ });
234
+ });
235
+ }