toolstile 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agents.d.ts CHANGED
@@ -30,7 +30,8 @@ export declare class ManualEditError extends Error {
30
30
  constructor(target: Target, reason: string, snippet: string);
31
31
  }
32
32
  /** Replaces the file in one step and keeps its permission bits; a symlink keeps pointing at it. */
33
- export declare function writeText(link: string, text: string): void;
33
+ export declare function writeText(link: string, text: string, expected?: string): void;
34
+ export declare function updateText(link: string, update: (text: string | null) => string): void;
34
35
  /**
35
36
  * Drops `[mcp_servers.<name>]` and its sub-tables, keeping every other line and comment. With
36
37
  * `replacement`, that text goes where the table was (or at the end when there was none).
package/dist/agents.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { fileText, withFileLock } from "./file-lock.js";
2
+ import { randomUUID } from "node:crypto";
1
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
4
  import { dirname, join } from "node:path";
3
5
  import { parse as parseToml } from "smol-toml";
@@ -65,7 +67,17 @@ function readText(path) {
65
67
  }
66
68
  }
67
69
  /** Replaces the file in one step and keeps its permission bits; a symlink keeps pointing at it. */
68
- export function writeText(link, text) {
70
+ export function writeText(link, text, expected) {
71
+ return withFileLock(link, () => {
72
+ if (expected !== undefined && fileText(link) !== expected)
73
+ throw new Error("The config changed while it was being edited; run the command again.");
74
+ writeTextLocked(link, text);
75
+ });
76
+ }
77
+ export function updateText(link, update) {
78
+ return withFileLock(link, () => writeTextLocked(link, update(fileText(link))));
79
+ }
80
+ function writeTextLocked(link, text) {
69
81
  let path = link;
70
82
  let mode = 0o600;
71
83
  try {
@@ -75,7 +87,7 @@ export function writeText(link, text) {
75
87
  catch {
76
88
  mkdirSync(dirname(path), { recursive: true });
77
89
  }
78
- const temp = `${path}.toolstile-${process.pid}.tmp`;
90
+ const temp = `${path}.toolstile-${process.pid}-${randomUUID()}.tmp`;
79
91
  try {
80
92
  writeFileSync(temp, text, { mode });
81
93
  chmodSync(temp, mode);
@@ -142,6 +154,9 @@ export function readEntry(target) {
142
154
  /** `replaced` means a hand-written `toolstile` server (not the proxy) was swapped out. */
143
155
  const outcome = (command, existing) => !command ? "removed" : existing === undefined ? "added" : isProxyEntry(existing) ? "updated" : "replaced";
144
156
  export function writeEntry(target, command) {
157
+ return withFileLock(target.path, () => writeEntryLocked(target, command));
158
+ }
159
+ function writeEntryLocked(target, command) {
145
160
  const text = readText(target.path) ?? "";
146
161
  if (target.format === "codex") {
147
162
  let current;
@@ -168,7 +183,9 @@ export function writeEntry(target, command) {
168
183
  throw new ManualEditError(target, "its toolstile server is written in a form setup cannot edit", command ? tomlEntry(command) : "");
169
184
  if (next === text)
170
185
  return "unchanged";
171
- writeText(target.path, next);
186
+ if ((readText(target.path) ?? "") !== text)
187
+ throw new Error("The agent config changed; run setup again.");
188
+ writeTextLocked(target.path, next);
172
189
  return outcome(command, existing);
173
190
  }
174
191
  const key = serversKey(target.format);
@@ -201,6 +218,8 @@ export function writeEntry(target, command) {
201
218
  delete servers[ENTRY];
202
219
  object[key] = servers;
203
220
  const indent = text.match(/\n([ \t]+)"/)?.[1] ?? " ";
204
- writeText(target.path, `${JSON.stringify(object, null, indent)}\n`);
221
+ if ((readText(target.path) ?? "") !== text)
222
+ throw new Error("The agent config changed; run setup again.");
223
+ writeTextLocked(target.path, `${JSON.stringify(object, null, indent)}\n`);
205
224
  return outcome(entry, existing);
206
225
  }
package/dist/api.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { boundedText, deadline } from "./bounds.js";
1
2
  export const CLIENT_ID = "toolstile-cli";
2
3
  export class ApiError extends Error {
3
4
  status;
@@ -10,26 +11,37 @@ export class ApiError extends Error {
10
11
  }
11
12
  }
12
13
  async function request(fetcher, url, init = {}) {
13
- let response;
14
- try {
15
- response = await fetcher(url, {
16
- ...init,
17
- // Never follow a redirect with a session token to another address.
18
- redirect: "error",
19
- headers: { "Content-Type": "application/json", Accept: "application/json", ...init.headers },
20
- });
21
- }
22
- catch {
14
+ return deadline(async (signal) => {
15
+ let response;
16
+ try {
17
+ response = await fetcher(url, {
18
+ ...init,
19
+ signal,
20
+ // Never follow a redirect with a session token to another address.
21
+ redirect: "error",
22
+ headers: { "Content-Type": "application/json", Accept: "application/json", ...init.headers },
23
+ });
24
+ }
25
+ catch {
26
+ throw new ApiError(0, `Could not reach ${new URL(url).origin}. Check your connection.`);
27
+ }
28
+ if (response.status === 204)
29
+ return undefined;
30
+ const body = (await boundedText(response).then((text) => JSON.parse(text)).catch((error) => {
31
+ if (error instanceof SyntaxError)
32
+ return null;
33
+ throw error;
34
+ }));
35
+ if (!response.ok) {
36
+ const message = body?.error_description ?? body?.message;
37
+ throw new ApiError(response.status, typeof message === "string" ? message : `Request failed (${response.status})`, typeof body?.error === "string" ? body.error : undefined);
38
+ }
39
+ return body;
40
+ }, 30_000, init.signal ?? undefined).catch((error) => {
41
+ if (error instanceof ApiError)
42
+ throw error;
23
43
  throw new ApiError(0, `Could not reach ${new URL(url).origin}. Check your connection.`);
24
- }
25
- if (response.status === 204)
26
- return undefined;
27
- const body = (await response.json().catch(() => null));
28
- if (!response.ok) {
29
- const message = body?.error_description ?? body?.message;
30
- throw new ApiError(response.status, typeof message === "string" ? message : `Request failed (${response.status})`, typeof body?.error === "string" ? body.error : undefined);
31
- }
32
- return body;
44
+ });
33
45
  }
34
46
  /** The Toolstile endpoints the CLI uses. Session calls carry the device-flow session token. */
35
47
  export function createApi(server, fetcher = fetch) {
@@ -0,0 +1,3 @@
1
+ /** Bounds short operations even when a transport ignores its abort signal. */
2
+ export declare function deadline<T>(run: (signal: AbortSignal) => Promise<T>, ms: number, parent?: AbortSignal): Promise<T>;
3
+ export declare function boundedText(response: Response, limit?: number): Promise<string>;
package/dist/bounds.js ADDED
@@ -0,0 +1,43 @@
1
+ /** Bounds short operations even when a transport ignores its abort signal. */
2
+ export async function deadline(run, ms, parent) {
3
+ const controller = new AbortController();
4
+ let timer;
5
+ let abort = () => { };
6
+ const stopped = new Promise((_, reject) => {
7
+ abort = () => { controller.abort(); reject(new Error("Request cancelled or timed out")); };
8
+ parent?.addEventListener("abort", abort, { once: true });
9
+ timer = setTimeout(abort, ms);
10
+ });
11
+ try {
12
+ if (parent?.aborted)
13
+ abort();
14
+ return await Promise.race([stopped, Promise.resolve().then(() => run(controller.signal))]);
15
+ }
16
+ finally {
17
+ clearTimeout(timer);
18
+ parent?.removeEventListener("abort", abort);
19
+ }
20
+ }
21
+ export async function boundedText(response, limit = 1024 * 1024) {
22
+ if (!response.body)
23
+ return "";
24
+ const reader = response.body.getReader();
25
+ const decoder = new TextDecoder();
26
+ let size = 0;
27
+ let text = "";
28
+ try {
29
+ while (true) {
30
+ const { done, value } = await reader.read();
31
+ if (done)
32
+ return text + decoder.decode();
33
+ size += value.byteLength;
34
+ if (size > limit)
35
+ throw new Error("Response exceeded the size limit");
36
+ text += decoder.decode(value, { stream: true });
37
+ }
38
+ }
39
+ finally {
40
+ void reader.cancel().catch(() => { });
41
+ reader.releaseLock();
42
+ }
43
+ }
package/dist/commands.js CHANGED
@@ -6,13 +6,24 @@ import { checklist } from "./prompt.js";
6
6
  import { runProxy } from "./proxy.js";
7
7
  import { tildify } from "./report.js";
8
8
  import { CLIENT_NAMES } from "./sources.js";
9
- import { loadState, saveState } from "./state.js";
9
+ import { loadState, saveState, withCurrentState } from "./state.js";
10
10
  /** A failure explained to the user; exits 1 without a stack trace. */
11
11
  export class UserError extends Error {
12
12
  }
13
13
  const stateEnv = (ctx) => ({ home: ctx.home, env: ctx.env, platform: ctx.platform });
14
14
  const print = (ctx, text = "") => ctx.stdout.write(`${text}\n`);
15
15
  const interactive = (ctx) => ctx.stdin.isTTY === true && ctx.stdout.isTTY === true;
16
+ /** A failed local save must not silently abandon a newly issued remote credential. */
17
+ async function compensate(error, cleanup, remedy) {
18
+ try {
19
+ await cleanup();
20
+ }
21
+ catch (cleanupError) {
22
+ if (!(cleanupError instanceof ApiError && cleanupError.status === 404))
23
+ throw new UserError(`${error instanceof Error ? error.message : "Could not save credentials."} ${remedy}`);
24
+ }
25
+ throw error;
26
+ }
16
27
  /** HTTPS, except a loopback server for local development. */
17
28
  export function normalizeServer(value) {
18
29
  let url;
@@ -107,25 +118,37 @@ export async function login(ctx, options) {
107
118
  throw error;
108
119
  }
109
120
  }
110
- const account = await api.account(token);
111
121
  const previous = state.session;
112
- // The old session is ended where it was issued, never sent to the new server.
113
- if (previous)
114
- await ctx.api(previous.server ?? state.server).signOut(previous.token).catch(() => undefined);
115
- if (server !== state.server || (previous && previous.email !== account.email)) {
116
- // Another account's workspace and device do not apply; enabled agents keep their own access.
117
- delete state.workspace;
118
- delete state.device;
122
+ const previousServer = previous?.server ?? state.server;
123
+ let deviceLabel;
124
+ try {
125
+ const account = await api.account(token);
126
+ deviceLabel = account.device?.label;
127
+ if (server !== state.server || (previous && previous.email !== account.email)) {
128
+ // Another account's workspace and device do not apply; agents keep their own access.
129
+ delete state.workspace;
130
+ delete state.device;
131
+ }
132
+ state.server = server;
133
+ state.session = { token, email: account.email, expiresAt: account.expiresAt, server };
134
+ if (account.device)
135
+ state.device = account.device;
136
+ saveState(state, stateEnv(ctx));
119
137
  }
120
- state.server = server;
121
- state.session = { token, email: account.email, expiresAt: account.expiresAt, server };
122
- // The device named on the approval page. Agents still on an older device move to it at
123
- // the next `mcp setup`.
124
- if (account.device)
125
- state.device = account.device;
126
- saveState(state, stateEnv(ctx));
138
+ catch (error) {
139
+ await compensate(error, async () => {
140
+ // Only discard this sign-in, never the session a concurrent command saved.
141
+ const winner = loadState(stateEnv(ctx)).session;
142
+ if (winner?.token !== token || winner.server !== server)
143
+ await api.signOut(token);
144
+ }, "Sign-in cleanup failed. Remove this new CLI session in the Toolstile dashboard.");
145
+ }
146
+ // End the previous session only after the replacement was safely saved.
147
+ if (previous && (previous.token !== token || previousServer !== server))
148
+ await ctx.api(previousServer).signOut(previous.token).catch(() => undefined);
149
+ const account = state.session;
127
150
  print(ctx);
128
- print(ctx, `Signed in as ${s.bold(account.email)}${account.device ? ` on ${s.bold(account.device.label)}` : ""}. Next: ${s.bold("toolstile mcp setup")}`);
151
+ print(ctx, `Signed in as ${s.bold(account.email)}${deviceLabel ? ` on ${s.bold(deviceLabel)}` : ""}. Next: ${s.bold("toolstile mcp setup")}`);
129
152
  return 0;
130
153
  }
131
154
  export async function logout(ctx) {
@@ -134,17 +157,18 @@ export async function logout(ctx) {
134
157
  print(ctx, "Not signed in.");
135
158
  return 0;
136
159
  }
160
+ const session = state.session;
161
+ delete state.session;
162
+ saveState(state, stateEnv(ctx));
137
163
  let reached = true;
138
164
  await ctx
139
- .api(state.session.server ?? state.server)
140
- .signOut(state.session.token)
165
+ .api(session.server ?? state.server)
166
+ .signOut(session.token)
141
167
  .catch((error) => {
142
168
  // 401 means the session had already ended; anything else is worth saying.
143
169
  if (!(error instanceof ApiError && error.status === 401))
144
170
  reached = false;
145
171
  });
146
- delete state.session;
147
- saveState(state, stateEnv(ctx));
148
172
  print(ctx, reached ? "Signed out." : "Signed out on this machine. Toolstile could not be reached, so the session ends when it expires.");
149
173
  const enabled = Object.keys(state.agents).length;
150
174
  if (enabled)
@@ -274,13 +298,12 @@ export async function setup(ctx, options) {
274
298
  const want = wanted.has(target.client);
275
299
  const link = state.agents[target.client];
276
300
  if (want) {
301
+ let replacedAuthorization = false;
277
302
  if (live(target.client) && link && link.endpoint !== endpoint) {
278
303
  link.endpoint = endpoint;
279
304
  saveState(state, stateEnv(ctx));
280
305
  }
281
306
  if (!live(target.client)) {
282
- if (link)
283
- await removeRemote(link).catch(() => undefined);
284
307
  const { id: deviceId } = await ensureDevice();
285
308
  const created = await api.addAgent(session.token, { workspaceId: workspace.id, deviceId, label: target.name });
286
309
  state.agents[target.client] = {
@@ -291,11 +314,22 @@ export async function setup(ctx, options) {
291
314
  label: created.label,
292
315
  token: created.token,
293
316
  };
294
- // Saved before touching the agent's config, so a token is never lost.
295
- saveState(state, stateEnv(ctx));
317
+ // Commit before editing config or revoking any previous authorization.
318
+ try {
319
+ saveState(state, stateEnv(ctx));
320
+ }
321
+ catch (error) {
322
+ await compensate(error, async () => {
323
+ const winner = loadState(stateEnv(ctx));
324
+ const retained = Object.values(winner.agents).some((agent) => agent?.id === created.id && agent.workspaceId === workspace.id && agent.server === state.server);
325
+ if (!retained)
326
+ await api.removeAgent(session.token, workspace.id, created.id);
327
+ }, `Agent cleanup failed. Remove the new ${target.name} authorization (${created.id}) in the Toolstile dashboard.`);
328
+ }
329
+ replacedAuthorization = !!link;
296
330
  }
297
331
  try {
298
- const result = writeEntry(target, proxyCommand(target.client, ctx.version, ctx.platform));
332
+ const result = withCurrentState(state, stateEnv(ctx), () => writeEntry(target, proxyCommand(target.client, ctx.version, ctx.platform)));
299
333
  if (!live(target.client) || result !== "unchanged")
300
334
  changes.push(`${s.bold("+")} ${target.name} ${s.dim(tildify(target.path, ctx.home))}`);
301
335
  if (result === "replaced")
@@ -306,20 +340,35 @@ export async function setup(ctx, options) {
306
340
  throw error;
307
341
  manual.push({ target, error, enable: true });
308
342
  }
343
+ finally {
344
+ // Finish both local writes before yielding to remote cleanup. A later
345
+ // disable must not be followed by this operation recreating its config.
346
+ if (replacedAuthorization && link)
347
+ await removeRemote(link).catch(() => undefined);
348
+ }
309
349
  }
310
350
  else if (link || isProxyEntry(readEntry(target))) {
311
- try {
312
- writeEntry(target, null);
313
- }
314
- catch (error) {
315
- if (!(error instanceof ManualEditError))
316
- throw error;
317
- manual.push({ target, error, enable: false });
318
- }
351
+ const removeLocal = () => {
352
+ try {
353
+ writeEntry(target, null);
354
+ }
355
+ catch (error) {
356
+ if (!(error instanceof ManualEditError))
357
+ throw error;
358
+ manual.push({ target, error, enable: false });
359
+ }
360
+ if (link)
361
+ delete state.agents[target.client];
362
+ };
363
+ // Both local writes form one transition, even across separate processes.
364
+ if (link)
365
+ saveState(state, stateEnv(ctx), removeLocal);
366
+ else
367
+ withCurrentState(state, stateEnv(ctx), removeLocal);
319
368
  if (link) {
320
- await removeRemote(link);
321
- delete state.agents[target.client];
322
- saveState(state, stateEnv(ctx));
369
+ await removeRemote(link).catch(() => {
370
+ throw new UserError(`Disabled locally, but remote cleanup failed. Remove ${target.name} authorization (${link.id}) in the Toolstile dashboard.`);
371
+ });
323
372
  }
324
373
  changes.push(`${s.bold("-")} ${target.name}`);
325
374
  }
@@ -0,0 +1,3 @@
1
+ export declare function fileText(path: string): string | null;
2
+ /** Fail closed rather than steal a lock from a possibly live command. */
3
+ export declare function withFileLock<T>(path: string, run: () => T): T;
@@ -0,0 +1,38 @@
1
+ import { mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ export function fileText(path) {
4
+ try {
5
+ return readFileSync(path, "utf8");
6
+ }
7
+ catch (error) {
8
+ if (error.code === "ENOENT")
9
+ return null;
10
+ throw error;
11
+ }
12
+ }
13
+ /** Fail closed rather than steal a lock from a possibly live command. */
14
+ export function withFileLock(path, run) {
15
+ mkdirSync(dirname(path), { recursive: true });
16
+ let canonical;
17
+ try {
18
+ canonical = realpathSync(path);
19
+ }
20
+ catch {
21
+ canonical = resolve(realpathSync(dirname(path)), path.split(/[\\/]/).pop());
22
+ }
23
+ const lock = `${canonical}.toolstile-lock`;
24
+ try {
25
+ mkdirSync(lock, { mode: 0o700 });
26
+ }
27
+ catch (error) {
28
+ if (error.code === "EEXIST")
29
+ throw new Error(`Another command is updating ${path}. Retry when it finishes. If it crashed, remove ${lock}.`);
30
+ throw error;
31
+ }
32
+ try {
33
+ return run();
34
+ }
35
+ finally {
36
+ rmSync(lock, { recursive: true });
37
+ }
38
+ }
package/dist/fix.js CHANGED
@@ -2,7 +2,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { applyEdits, modify } from "jsonc-parser";
4
4
  import { stringify as stringifyToml } from "smol-toml";
5
- import { withoutTomlTable, writeText } from "./agents.js";
5
+ import { withoutTomlTable, writeText, updateText } from "./agents.js";
6
6
  import { isPinned, packageOf, secretsOf, shellPath, words } from "./checks.js";
7
7
  import { audit } from "./index.js";
8
8
  import { parseConfig } from "./parse.js";
@@ -474,35 +474,31 @@ export function applyFixes(plan, chosen, opts) {
474
474
  const secrets = ok.flatMap((f) => f.secrets);
475
475
  let profileAdded = false;
476
476
  if (secrets.length) {
477
- const stored = readSecrets(plan.secretsFile);
478
- let text = "";
479
- try {
480
- text = readFileSync(plan.secretsFile, "utf8");
481
- }
482
- catch {
483
- text = "# MCP server secrets moved out of agent configs by toolstile audit --fix.\n# Only your user can read this file. Loaded by your shell; see the line in your shell profile.\n";
484
- }
485
- for (const { name, value } of secrets) {
486
- if (stored.get(name) === value)
487
- continue;
488
- stored.set(name, value);
489
- text = `${text.replace(/\n?$/, "\n")}export ${name}=${quote(value)}\n`;
490
- }
491
477
  mkdirSync(dirname(plan.secretsFile), { recursive: true, mode: 0o700 });
492
- writeText(plan.secretsFile, text);
478
+ updateText(plan.secretsFile, (current) => {
479
+ const stored = readSecrets(plan.secretsFile);
480
+ let text = current ?? "# MCP server secrets moved out of agent configs by toolstile audit --fix.\n# Only your user can read this file. Loaded by your shell; see the line in your shell profile.\n";
481
+ for (const { name, value } of secrets) {
482
+ if (stored.get(name) === value)
483
+ continue;
484
+ if (stored.has(name))
485
+ throw new Error("A saved secret changed; run --fix again.");
486
+ stored.set(name, value);
487
+ text = `${text.replace(/\n?$/, "\n")}export ${name}=${quote(value)}\n`;
488
+ }
489
+ return text;
490
+ });
493
491
  chmodSync(plan.secretsFile, 0o600);
494
492
  if (plan.profile) {
495
- let profile = "";
496
- try {
497
- profile = readFileSync(plan.profile.path, "utf8");
498
- }
499
- catch { }
500
- if (!profile.includes(shellPath(plan.secretsFile, opts.home)) && !profile.includes(plan.secretsFile)) {
501
- const mode = existsSync(plan.profile.path) ? statSync(plan.profile.path).mode & 0o777 : 0o644;
502
- writeText(plan.profile.path, `${profile}${profile && !profile.endsWith("\n") ? "\n" : ""}\n${plan.profile.line}\n`);
503
- chmodSync(plan.profile.path, mode);
493
+ const mode = existsSync(plan.profile.path) ? statSync(plan.profile.path).mode & 0o777 : 0o644;
494
+ updateText(plan.profile.path, (current) => {
495
+ const profile = current ?? "";
496
+ if (profile.includes(shellPath(plan.secretsFile, opts.home)) || profile.includes(plan.secretsFile))
497
+ return profile;
504
498
  profileAdded = true;
505
- }
499
+ return `${profile}${profile && !profile.endsWith("\n") ? "\n" : ""}\n${plan.profile.line}\n`;
500
+ });
501
+ chmodSync(plan.profile.path, mode);
506
502
  }
507
503
  }
508
504
  let backups = null;
@@ -521,8 +517,8 @@ export function applyFixes(plan, chosen, opts) {
521
517
  copyFileSync(file, copy);
522
518
  chmodSync(copy, 0o600);
523
519
  }
524
- for (const [file, { after }] of texts)
525
- writeText(file, after);
520
+ for (const [file, { before, after }] of texts)
521
+ writeText(file, after, before);
526
522
  }
527
523
  for (const fix of ok)
528
524
  for (const op of fix.ops)
package/dist/proxy.js CHANGED
@@ -1,22 +1,61 @@
1
- import { createInterface } from "node:readline";
1
+ import { boundedText, deadline } from "./bounds.js";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ // Replies and notifications can unblock an active request, even during initialization.
4
+ const urgent = (message) => message.method === undefined ||
5
+ (message.id === undefined && message.method !== "notifications/initialized");
6
+ function isMessage(value) {
7
+ if (!value || typeof value !== "object" || Array.isArray(value))
8
+ return false;
9
+ const m = value;
10
+ if (m.jsonrpc !== "2.0")
11
+ return false;
12
+ if ("id" in m && m.id !== null && typeof m.id !== "string" && !(typeof m.id === "number" && Number.isFinite(m.id)))
13
+ return false;
14
+ if ("method" in m)
15
+ return typeof m.method === "string" && !("result" in m) && !("error" in m) &&
16
+ (!("params" in m) || (m.params !== null && typeof m.params === "object"));
17
+ if (!("id" in m) || (("result" in m) === ("error" in m)))
18
+ return false;
19
+ if ("error" in m) {
20
+ const e = m.error;
21
+ return !!e && typeof e === "object" && Number.isInteger(e.code) && typeof e.message === "string";
22
+ }
23
+ return true;
24
+ }
2
25
  /** Yields each SSE event's data and id. */
3
26
  async function* events(body) {
4
27
  const decoder = new TextDecoder();
5
28
  let buffer = "";
6
29
  let data = [];
7
30
  let id;
31
+ let eventBytes = 0;
32
+ let lineBytes = 0;
33
+ const append = (part) => {
34
+ lineBytes += Buffer.byteLength(part, "utf8");
35
+ if (lineBytes > 1024 * 1024)
36
+ throw new Error("SSE line exceeded the size limit");
37
+ buffer += part;
38
+ };
8
39
  for await (const chunk of body) {
9
- buffer += decoder.decode(chunk, { stream: true });
40
+ const text = decoder.decode(chunk, { stream: true });
41
+ let start = 0;
10
42
  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);
43
+ while ((newline = text.indexOf("\n", start)) !== -1) {
44
+ append(text.slice(start, newline));
45
+ const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
46
+ buffer = "";
47
+ lineBytes = 0;
48
+ start = newline + 1;
14
49
  if (line === "") {
15
50
  if (data.length)
16
51
  yield { data: data.join("\n"), id };
17
52
  data = [];
53
+ eventBytes = 0;
18
54
  continue;
19
55
  }
56
+ eventBytes += Buffer.byteLength(line, "utf8");
57
+ if (eventBytes > 1024 * 1024)
58
+ throw new Error("SSE event exceeded the size limit");
20
59
  if (line.startsWith(":"))
21
60
  continue;
22
61
  const colon = line.indexOf(":");
@@ -27,10 +66,37 @@ async function* events(body) {
27
66
  else if (field === "id")
28
67
  id = value;
29
68
  }
69
+ append(text.slice(start));
30
70
  }
71
+ append(decoder.decode());
31
72
  if (data.length)
32
73
  yield { data: data.join("\n"), id };
33
74
  }
75
+ /** Frame incrementally rather than letting readline queue unbounded complete lines. */
76
+ async function* inputLines(input) {
77
+ const decoder = new StringDecoder("utf8");
78
+ let buffer = "";
79
+ const append = (part) => {
80
+ if (Buffer.byteLength(buffer) + Buffer.byteLength(part) > 1024 * 1024)
81
+ throw new Error("Input line exceeded the size limit");
82
+ buffer += part;
83
+ };
84
+ for await (const chunk of input) {
85
+ const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
86
+ let start = 0;
87
+ let newline;
88
+ while ((newline = text.indexOf("\n", start)) !== -1) {
89
+ append(text.slice(start, newline));
90
+ yield buffer;
91
+ buffer = "";
92
+ start = newline + 1;
93
+ }
94
+ append(text.slice(start));
95
+ }
96
+ append(decoder.end());
97
+ if (buffer)
98
+ yield buffer;
99
+ }
34
100
  /** How long in-flight requests may finish after the agent closes stdin. */
35
101
  const CLOSE_GRACE_MS = 3000;
36
102
  export function runProxy(options) {
@@ -39,16 +105,51 @@ export function runProxy(options) {
39
105
  let protocolVersion;
40
106
  /** The agent's own `initialize`, replayed if the gateway forgets the session. */
41
107
  let opening;
42
- let listening = false;
108
+ let listener;
109
+ let recovering;
43
110
  let closed = false;
44
111
  /** Aborts every request and the listener's waits once the agent has gone. */
45
112
  const shutdown = new AbortController();
46
113
  const pending = new Set();
47
114
  /** Later messages wait for `initialize`, so they carry the session it opens. */
48
115
  let initialized = Promise.resolve();
116
+ // Requests and control messages have independent budgets. No waiting input queue:
117
+ // overload is answered locally, and exhausted control/output budgets close the proxy.
118
+ const LIMIT = 64;
119
+ let requestCount = 0;
120
+ let controlCount = 0;
121
+ let finishForced;
122
+ const forced = new Promise((resolve) => { finishForced = resolve; });
123
+ const stop = () => {
124
+ closed = true;
125
+ shutdown.abort();
126
+ options.input.destroy();
127
+ finishForced();
128
+ };
129
+ let writes = Promise.resolve();
130
+ let queuedWrites = 0;
131
+ let queuedBytes = 0;
49
132
  const write = (message) => {
50
- if (!options.output.writableEnded)
51
- options.output.write(`${JSON.stringify(message)}\n`);
133
+ if (closed)
134
+ return Promise.resolve();
135
+ const text = `${JSON.stringify(message)}\n`;
136
+ const bytes = Buffer.byteLength(text);
137
+ if (queuedWrites >= 128 || queuedBytes + bytes > 8 * 1024 * 1024) {
138
+ options.log("Proxy output limit exceeded; closing the connection.");
139
+ stop();
140
+ return Promise.resolve();
141
+ }
142
+ queuedWrites++;
143
+ queuedBytes += bytes;
144
+ writes = writes.then(() => new Promise((resolve, reject) => {
145
+ if (closed || options.output.writableEnded)
146
+ return resolve();
147
+ const finish = (error) => error ? reject(error) : resolve();
148
+ options.output.write(text, finish);
149
+ })).finally(() => { queuedWrites--; queuedBytes -= bytes; });
150
+ // Observe immediately; send/readBody await the same promise to apply backpressure.
151
+ void writes.catch(stop);
152
+ return writes;
52
153
  };
53
154
  const headers = (extra = {}) => ({
54
155
  Authorization: `Bearer ${options.token}`,
@@ -57,7 +158,9 @@ export function runProxy(options) {
57
158
  ...extra,
58
159
  });
59
160
  /** 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 });
161
+ const request = (init) => shutdown.signal.aborted
162
+ ? Promise.reject(new Error("The agent closed the connection"))
163
+ : fetcher(options.endpoint, { ...init, redirect: "error", signal: init.signal ?? shutdown.signal });
61
164
  const post = (body) => request({
62
165
  method: "POST",
63
166
  headers: headers({ "Content-Type": "application/json", Accept: "application/json, text/event-stream" }),
@@ -78,17 +181,24 @@ export function runProxy(options) {
78
181
  }
79
182
  if (message.method === undefined && message.id !== undefined)
80
183
  answered?.add(message.id);
81
- write(message);
184
+ return write(message);
82
185
  };
83
186
  async function readBody(response, answered, quiet = false) {
84
187
  const type = response.headers.get("content-type") ?? "";
85
- const take = (message) => (quiet ? answered.add(message.id) : deliver(message, answered));
188
+ const take = async (message) => {
189
+ if (!isMessage(message))
190
+ throw new Error("Invalid JSON-RPC response");
191
+ if (quiet)
192
+ answered.add(message.id);
193
+ else
194
+ await deliver(message, answered);
195
+ };
86
196
  if (!response.body)
87
197
  return;
88
198
  if (type.includes("text/event-stream")) {
89
199
  for await (const event of events(response.body)) {
90
200
  try {
91
- take(JSON.parse(event.data));
201
+ await take(JSON.parse(event.data));
92
202
  }
93
203
  catch {
94
204
  options.log("Ignored a malformed message from Toolstile.");
@@ -97,55 +207,79 @@ export function runProxy(options) {
97
207
  return;
98
208
  }
99
209
  if (type.includes("application/json")) {
100
- const body = (await response.json());
210
+ const body = JSON.parse(await boundedText(response));
101
211
  for (const message of Array.isArray(body) ? body : [body])
102
- take(message);
212
+ await take(message);
103
213
  }
104
214
  }
105
215
  const wait = (ms) => new Promise((resolve) => {
106
- const timer = setTimeout(resolve, ms);
107
- shutdown.signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
216
+ const done = () => { clearTimeout(timer); shutdown.signal.removeEventListener("abort", done); resolve(); };
217
+ const timer = setTimeout(done, ms);
218
+ if (shutdown.signal.aborted)
219
+ done();
220
+ else
221
+ shutdown.signal.addEventListener("abort", done, { once: true });
108
222
  });
109
223
  /** Server-initiated messages (list changes, approvals) arrive on a long-lived GET stream. */
110
224
  async function listen() {
111
- if (listening || !sessionId)
225
+ if (listener || !sessionId || closed)
112
226
  return;
113
- listening = true;
227
+ const current = listener = new AbortController();
228
+ const stop = () => current.abort();
229
+ shutdown.signal.addEventListener("abort", stop, { once: true });
114
230
  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));
231
+ try {
232
+ for (let attempt = 0; !closed && !current.signal.aborted && attempt < 8; attempt++) {
233
+ try {
234
+ const response = await request({
235
+ method: "GET",
236
+ signal: current.signal,
237
+ headers: headers({ Accept: "text/event-stream", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) }),
238
+ });
239
+ // 405: this server offers no stream; that is allowed.
240
+ if (response.status === 405 || !response.ok || !response.body) {
241
+ void response.body?.cancel().catch(() => undefined);
242
+ break;
243
+ }
244
+ attempt = 0;
245
+ for await (const event of events(response.body)) {
246
+ if (current.signal.aborted)
247
+ break;
248
+ if (event.id)
249
+ lastEventId = event.id;
250
+ try {
251
+ const message = JSON.parse(event.data);
252
+ if (isMessage(message))
253
+ await deliver(message);
254
+ }
255
+ catch { }
130
256
  }
131
- catch { }
132
257
  }
133
- }
134
- catch {
135
- if (closed)
258
+ catch {
259
+ if (closed)
260
+ break;
261
+ }
262
+ if (current.signal.aborted)
136
263
  break;
264
+ await wait(Math.min(30_000, 1000 * 2 ** attempt));
137
265
  }
138
- await wait(Math.min(30_000, 1000 * 2 ** attempt));
139
266
  }
140
- listening = false;
267
+ finally {
268
+ current.abort();
269
+ shutdown.signal.removeEventListener("abort", stop);
270
+ if (listener === current)
271
+ listener = undefined;
272
+ }
141
273
  }
142
274
  /**
143
275
  * The gateway answers 404 for a session it no longer has (for example after a restart).
144
276
  * Open a new one with the agent's original `initialize`, without showing its reply again.
145
277
  */
146
- async function reopen() {
278
+ async function replaceSession() {
147
279
  if (!opening)
148
280
  return false;
281
+ listener?.abort();
282
+ listener = undefined;
149
283
  sessionId = undefined;
150
284
  const response = await post(opening);
151
285
  sessionId = response.headers.get("mcp-session-id") ?? undefined;
@@ -153,83 +287,174 @@ export function runProxy(options) {
153
287
  return false;
154
288
  await readBody(response, new Set(), true);
155
289
  await post(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })).then((r) => r.body?.cancel());
156
- void listen();
290
+ void listen().catch((error) => options.log(String(error)));
157
291
  return true;
158
292
  }
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
- }
293
+ function reopen(stale) {
294
+ if (recovering)
295
+ return recovering;
296
+ if (sessionId !== stale)
297
+ return Promise.resolve(true);
298
+ recovering = replaceSession().finally(() => { recovering = undefined; });
299
+ return recovering;
300
+ }
301
+ async function send(messages, batch) {
302
+ const line = JSON.stringify(batch ? messages : messages[0]);
169
303
  const requests = messages.filter((m) => m.method !== undefined && m.id !== undefined);
170
304
  const answered = new Set();
171
- const fail = (message) => {
305
+ const fail = async (message) => {
172
306
  for (const request of requests)
173
307
  if (!answered.has(request.id))
174
- write({ jsonrpc: "2.0", id: request.id, error: { code: -32000, message } });
308
+ await write({ jsonrpc: "2.0", id: request.id, error: { code: -32000, message } });
175
309
  };
176
310
  if (messages.some((m) => m.method === "initialize"))
177
311
  opening = line;
178
312
  try {
313
+ if (recovering && !messages.every(urgent))
314
+ await recovering;
315
+ const stale = sessionId;
179
316
  let response = await post(line);
180
- if (response.status === 404 && sessionId && !messages.some((m) => m.method === "initialize")) {
317
+ if (response.status === 404 && stale && !messages.some((m) => m.method === "initialize")) {
181
318
  await response.body?.cancel();
182
- if (await reopen())
319
+ if (await reopen(stale))
183
320
  response = await post(line);
184
321
  }
185
322
  const session = response.headers.get("mcp-session-id");
186
- if (session)
323
+ if (session && (sessionId === stale || messages.some((m) => m.method === "initialize")))
187
324
  sessionId = session;
188
325
  if (!response.ok) {
189
- const text = (await response.text().catch(() => "")).trim().slice(0, 300);
326
+ const text = (await boundedText(response, 4096).catch(() => "")).trim().slice(0, 300);
190
327
  const message = explain(response.status, text);
191
328
  options.log(message);
192
- fail(message);
329
+ await fail(message);
193
330
  return;
194
331
  }
195
332
  await readBody(response, answered);
196
333
  if (messages.some((m) => m.method === "notifications/initialized"))
197
- void listen();
334
+ void listen().catch((error) => options.log(String(error)));
198
335
  // 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.");
336
+ await fail("Toolstile closed the connection before answering. Try again.");
200
337
  }
201
338
  catch (error) {
202
339
  if (closed)
203
340
  return fail("The agent closed the connection.");
204
341
  const message = `Could not reach Toolstile: ${error.message}`;
205
342
  options.log(message);
206
- fail(message);
343
+ await fail(message);
207
344
  }
208
345
  }
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);
346
+ const invalid = (code, message, id = null) => {
347
+ // Immediate observation plus the global write budgets also bound parse/overload errors.
348
+ void write({ jsonrpc: "2.0", id, error: { code, message } }).catch(stop);
349
+ };
350
+ const accept = (line) => {
351
+ let parsed;
352
+ try {
353
+ parsed = JSON.parse(line);
354
+ }
355
+ catch {
356
+ invalid(-32700, "Parse error");
357
+ return;
358
+ }
359
+ const batch = Array.isArray(parsed);
360
+ const values = Array.isArray(parsed) ? parsed : [parsed];
361
+ if (values.length > LIMIT) {
362
+ invalid(-32600, "Batch exceeds the request limit");
363
+ return;
364
+ }
365
+ if (!values.length) {
366
+ invalid(-32600, "Invalid Request");
367
+ return;
368
+ }
369
+ const messages = [];
370
+ let requests = 0;
371
+ let controls = 0;
372
+ for (const value of values) {
373
+ if (!isMessage(value)) {
374
+ invalid(-32600, "Invalid Request");
375
+ continue;
376
+ }
377
+ if (value.method !== undefined && value.id !== undefined) {
378
+ if (requestCount + requests >= LIMIT) {
379
+ invalid(-32000, "Proxy request limit reached; try again after a request finishes.", value.id);
380
+ continue;
381
+ }
382
+ requests++;
383
+ }
384
+ else {
385
+ if (controlCount + controls >= LIMIT) {
386
+ options.log("Proxy control limit exceeded; closing the connection.");
387
+ stop();
388
+ return;
389
+ }
390
+ controls++;
391
+ }
392
+ messages.push(value);
393
+ }
394
+ if (!messages.length || closed)
395
+ return;
396
+ // A mixed batch must not leave a server reply behind the initialize it unblocks.
397
+ const immediate = messages.filter(urgent);
398
+ const regular = messages.filter((message) => !urgent(message));
399
+ if (regular.length)
400
+ schedule(regular, batch, requests, controls - immediate.length);
401
+ if (immediate.length)
402
+ schedule(immediate, batch, 0, immediate.length);
403
+ };
404
+ const schedule = (messages, batch, requests, controls) => {
405
+ requestCount += requests;
406
+ controlCount += controls;
407
+ const opens = messages.some((message) => message.method === "initialize");
408
+ const task = (opens || messages.every(urgent) ? send(messages, batch) : initialized.then(() => send(messages, batch)))
409
+ .catch((error) => options.log(`Proxy task failed: ${String(error)}`))
410
+ .finally(() => {
411
+ pending.delete(task);
412
+ requestCount -= requests;
413
+ controlCount -= controls;
219
414
  });
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);
415
+ if (opens)
416
+ initialized = task;
417
+ pending.add(task);
418
+ };
419
+ let closeTimer;
420
+ const inputEnded = () => {
421
+ closeTimer = setTimeout(stop, CLOSE_GRACE_MS);
422
+ };
423
+ options.input.once("end", inputEnded);
424
+ const outputError = () => stop();
425
+ options.output.on("error", outputError);
426
+ const work = (async () => {
427
+ try {
428
+ for await (const line of inputLines(options.input)) {
429
+ if (closed)
430
+ break;
431
+ if (line.trim())
432
+ accept(line);
433
+ }
434
+ }
435
+ catch (error) {
436
+ options.log(`Proxy input failed: ${String(error)}`);
437
+ stop();
438
+ }
439
+ // One total deadline covers in-flight work, blocked stdout and best-effort DELETE.
440
+ await deadline(async (signal) => {
226
441
  await Promise.allSettled([...pending]);
227
- clearTimeout(grace);
442
+ await writes;
228
443
  closed = true;
229
444
  shutdown.abort();
230
- if (sessionId)
231
- await fetcher(options.endpoint, { method: "DELETE", headers: headers(), redirect: "error" }).catch(() => undefined);
232
- resolve();
233
- });
445
+ if (sessionId) {
446
+ const response = await fetcher(options.endpoint, { method: "DELETE", headers: headers(), redirect: "error", signal });
447
+ void response.body?.cancel().catch(() => { });
448
+ }
449
+ }, CLOSE_GRACE_MS).catch(() => { });
450
+ closed = true;
451
+ shutdown.abort();
452
+ })();
453
+ return Promise.race([work, forced]).finally(() => {
454
+ clearTimeout(closeTimer);
455
+ options.input.removeListener("end", inputEnded);
456
+ closed = true;
457
+ shutdown.abort();
458
+ void writes.then(() => options.output.removeListener("error", outputError), () => options.output.removeListener("error", outputError));
234
459
  });
235
460
  }
package/dist/state.d.ts CHANGED
@@ -43,10 +43,8 @@ export interface StateEnv {
43
43
  /** `TOOLSTILE_CONFIG_DIR`, else the platform's per-user config folder. */
44
44
  export declare function stateDir({ home, env, platform }?: StateEnv): string;
45
45
  export declare const statePath: (opts?: StateEnv) => string;
46
- /**
47
- * The saved server always wins over `TOOLSTILE_URL`, which only picks the server for a new
48
- * `auth login`: an environment variable set by a project must not redirect saved tokens.
49
- */
50
46
  export declare function loadState(opts?: StateEnv): State;
47
+ /** Hold the credential revision stable during a synchronous config mutation. */
48
+ export declare function withCurrentState<T>(state: State, opts: StateEnv | undefined, run: () => T): T;
51
49
  /** Written to a temporary file and renamed, so a crash never leaves half a file. */
52
- export declare function saveState(state: State, opts?: StateEnv): void;
50
+ export declare function saveState(state: State, opts?: StateEnv, beforeWrite?: () => void): void;
package/dist/state.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { fileText, withFileLock } from "./file-lock.js";
2
+ import { randomUUID } from "node:crypto";
1
3
  import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
4
  import { homedir } from "node:os";
3
5
  import { join } from "node:path";
@@ -19,6 +21,7 @@ export const statePath = (opts) => join(stateDir(opts), "credentials.json");
19
21
  * The saved server always wins over `TOOLSTILE_URL`, which only picks the server for a new
20
22
  * `auth login`: an environment variable set by a project must not redirect saved tokens.
21
23
  */
24
+ const revisions = new WeakMap();
22
25
  export function loadState(opts) {
23
26
  const fallback = { server: DEFAULT_SERVER, agents: {} };
24
27
  let text;
@@ -26,32 +29,52 @@ export function loadState(opts) {
26
29
  text = readFileSync(statePath(opts), "utf8");
27
30
  }
28
31
  catch (error) {
29
- if (error.code === "ENOENT")
32
+ if (error.code === "ENOENT") {
33
+ revisions.set(fallback, null);
30
34
  return fallback;
35
+ }
31
36
  throw error;
32
37
  }
33
38
  try {
34
39
  const data = JSON.parse(text);
35
- return moveLegacyServer({ ...fallback, ...data, agents: data.agents ?? {} });
40
+ const state = moveLegacyServer({ ...fallback, ...data, agents: data.agents ?? {} });
41
+ revisions.set(state, text);
42
+ return state;
36
43
  }
37
44
  catch {
38
45
  throw new Error(`${statePath(opts)} is not valid JSON. Delete it and run toolstile auth login again.`);
39
46
  }
40
47
  }
48
+ /** Hold the credential revision stable during a synchronous config mutation. */
49
+ export function withCurrentState(state, opts, run) {
50
+ return withFileLock(statePath(opts), () => {
51
+ if (!revisions.has(state) || revisions.get(state) !== fileText(statePath(opts)))
52
+ throw new Error("Toolstile credentials changed in another command. Run this command again.");
53
+ return run();
54
+ });
55
+ }
41
56
  /** Written to a temporary file and renamed, so a crash never leaves half a file. */
42
- export function saveState(state, opts) {
57
+ export function saveState(state, opts, beforeWrite) {
43
58
  const dir = stateDir(opts);
44
59
  mkdirSync(dir, { recursive: true, mode: 0o700 });
45
60
  const path = statePath(opts);
46
- const temp = `${path}.${process.pid}.tmp`;
47
- try {
48
- writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
49
- chmodSync(temp, 0o600);
50
- renameSync(temp, path);
51
- }
52
- finally {
53
- rmSync(temp, { force: true });
54
- }
61
+ withFileLock(path, () => {
62
+ const before = fileText(path);
63
+ if (revisions.has(state) && revisions.get(state) !== before)
64
+ throw new Error("Toolstile credentials changed in another command. Run this command again.");
65
+ // Optional synchronous config/state transition shares this credential lock.
66
+ beforeWrite?.();
67
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
68
+ try {
69
+ writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
70
+ chmodSync(temp, 0o600);
71
+ renameSync(temp, path);
72
+ revisions.set(state, fileText(path));
73
+ }
74
+ finally {
75
+ rmSync(temp, { force: true });
76
+ }
77
+ });
55
78
  }
56
79
  /** Sign-ins and agents saved for the old bare-domain address keep working on the new hosts. */
57
80
  function moveLegacyServer(state) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolstile",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Audit the MCP server configs your AI agents use for plaintext secrets, unpinned packages, auto-approved tools and duplicated setups, and connect those agents to Toolstile without putting tokens in their configs.",
5
5
  "keywords": [
6
6
  "mcp",