toolstile 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -6
- package/dist/agents.d.ts +43 -0
- package/dist/agents.js +192 -0
- package/dist/api.d.ts +64 -0
- package/dist/api.js +61 -0
- package/dist/checks.d.ts +3 -0
- package/dist/checks.js +71 -22
- package/dist/cli.js +137 -6
- package/dist/commands.d.ts +37 -0
- package/dist/commands.js +338 -0
- package/dist/parse.d.ts +15 -1
- package/dist/parse.js +72 -13
- package/dist/prompt.d.ts +10 -0
- package/dist/prompt.js +54 -0
- package/dist/proxy.d.ts +15 -0
- package/dist/proxy.js +235 -0
- package/dist/report.js +1 -1
- package/dist/secrets.js +3 -3
- package/dist/sources.d.ts +1 -0
- package/dist/sources.js +19 -4
- package/dist/state.d.ts +48 -0
- package/dist/state.js +51 -0
- package/dist/types.d.ts +4 -1
- package/package.json +7 -5
package/dist/parse.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
|
-
import { parse as parseToml } from "smol-toml";
|
|
2
|
-
/**
|
|
1
|
+
import { parse as parseToml, TomlError } from "smol-toml";
|
|
2
|
+
/**
|
|
3
|
+
* A config file that could not be parsed. The message is fixed text plus a position: parser
|
|
4
|
+
* messages quote the offending source line, which can hold a secret, so they are never kept.
|
|
5
|
+
*/
|
|
6
|
+
export class ConfigParseError extends Error {
|
|
7
|
+
format;
|
|
8
|
+
line;
|
|
9
|
+
column;
|
|
10
|
+
constructor(format, line, column) {
|
|
11
|
+
super(`invalid ${format}${line ? ` at line ${line}${column ? `, column ${column}` : ""}` : ""}`);
|
|
12
|
+
this.format = format;
|
|
13
|
+
this.line = line;
|
|
14
|
+
this.column = column;
|
|
15
|
+
this.name = "ConfigParseError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parses JSON that may contain comments and trailing commas (VS Code style). Comments and
|
|
20
|
+
* trailing commas become spaces so parser positions still match the original text.
|
|
21
|
+
*/
|
|
3
22
|
export function parseJsonc(text) {
|
|
4
23
|
let out = "";
|
|
5
24
|
let inString = false;
|
|
@@ -22,15 +41,19 @@ export function parseJsonc(text) {
|
|
|
22
41
|
out += ch;
|
|
23
42
|
}
|
|
24
43
|
else if (ch === "/" && next === "/") {
|
|
25
|
-
while (i < text.length && text[i] !== "\n")
|
|
44
|
+
while (i < text.length && text[i] !== "\n") {
|
|
45
|
+
out += blank(text[i]);
|
|
26
46
|
i++;
|
|
27
|
-
|
|
47
|
+
}
|
|
48
|
+
if (i < text.length)
|
|
49
|
+
out += "\n";
|
|
28
50
|
}
|
|
29
51
|
else if (ch === "/" && next === "*") {
|
|
30
|
-
i
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
const end = text.indexOf("*/", i + 2);
|
|
53
|
+
const stop = end === -1 ? text.length : end + 2;
|
|
54
|
+
for (; i < stop; i++)
|
|
55
|
+
out += blank(text[i]);
|
|
56
|
+
i--;
|
|
34
57
|
}
|
|
35
58
|
else {
|
|
36
59
|
out += ch;
|
|
@@ -38,6 +61,8 @@ export function parseJsonc(text) {
|
|
|
38
61
|
}
|
|
39
62
|
return JSON.parse(stripTrailingCommas(out));
|
|
40
63
|
}
|
|
64
|
+
/** Keeps line breaks so line numbers survive; everything else becomes a space. */
|
|
65
|
+
const blank = (ch) => (ch === "\n" || ch === "\r" ? ch : " ");
|
|
41
66
|
function stripTrailingCommas(text) {
|
|
42
67
|
let out = "";
|
|
43
68
|
let inString = false;
|
|
@@ -60,17 +85,51 @@ function stripTrailingCommas(text) {
|
|
|
60
85
|
let j = i + 1;
|
|
61
86
|
while (j < text.length && /\s/.test(text[j]))
|
|
62
87
|
j++;
|
|
63
|
-
if (text[j] === "}" || text[j] === "]")
|
|
88
|
+
if (text[j] === "}" || text[j] === "]") {
|
|
89
|
+
out += " ";
|
|
64
90
|
continue;
|
|
91
|
+
}
|
|
65
92
|
}
|
|
66
93
|
out += ch;
|
|
67
94
|
}
|
|
68
95
|
return out;
|
|
69
96
|
}
|
|
97
|
+
/** 1-based line and column of a character offset. */
|
|
98
|
+
function lineColumn(text, offset) {
|
|
99
|
+
const before = text.slice(0, Math.min(Math.max(offset, 0), text.length));
|
|
100
|
+
const lastBreak = before.lastIndexOf("\n");
|
|
101
|
+
return { line: before.split("\n").length, column: offset - lastBreak };
|
|
102
|
+
}
|
|
103
|
+
/** Reads only the position from a V8 JSON error; the rest of its message may quote the file. */
|
|
104
|
+
function jsonPosition(error, text) {
|
|
105
|
+
const message = error instanceof Error ? error.message : "";
|
|
106
|
+
const at = /\bposition (\d+)/.exec(message);
|
|
107
|
+
if (at)
|
|
108
|
+
return lineColumn(text, Number(at[1]));
|
|
109
|
+
if (/Unexpected end of JSON input/.test(message))
|
|
110
|
+
return lineColumn(text, text.length);
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
/** Parses a config file. Throws only {@link ConfigParseError}, whose message never contains file text. */
|
|
70
114
|
export function parseConfig(path, text) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
115
|
+
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
116
|
+
if (path.endsWith(".toml")) {
|
|
117
|
+
try {
|
|
118
|
+
return parseToml(body);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof TomlError)
|
|
122
|
+
throw new ConfigParseError("TOML", error.line, error.column);
|
|
123
|
+
throw new ConfigParseError("TOML");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (body.trim() === "")
|
|
74
127
|
return {};
|
|
75
|
-
|
|
128
|
+
try {
|
|
129
|
+
return parseJsonc(body);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const { line, column } = jsonPosition(error, body);
|
|
133
|
+
throw new ConfigParseError("JSON", line, column);
|
|
134
|
+
}
|
|
76
135
|
}
|
package/dist/prompt.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReadStream, WriteStream } from "node:tty";
|
|
2
|
+
export interface Choice {
|
|
3
|
+
label: string;
|
|
4
|
+
hint?: string;
|
|
5
|
+
checked: boolean;
|
|
6
|
+
}
|
|
7
|
+
/** Terminal checklist: arrows (or j/k) move, space toggles, a toggles all, enter saves. */
|
|
8
|
+
export declare function checklist(title: string, choices: Choice[], input: ReadStream, output: WriteStream, options?: {
|
|
9
|
+
single?: boolean;
|
|
10
|
+
}): Promise<boolean[] | null>;
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { emitKeypressEvents } from "node:readline";
|
|
2
|
+
/** Terminal checklist: arrows (or j/k) move, space toggles, a toggles all, enter saves. */
|
|
3
|
+
export function checklist(title, choices, input, output, options = {}) {
|
|
4
|
+
const state = choices.map((c) => c.checked);
|
|
5
|
+
let cursor = Math.max(0, options.single ? state.indexOf(true) : 0);
|
|
6
|
+
let drawn = 0;
|
|
7
|
+
const help = options.single ? "↑/↓ move · enter select · esc cancel" : "↑/↓ move · space toggle · a all · enter save · esc cancel";
|
|
8
|
+
const render = () => {
|
|
9
|
+
if (drawn)
|
|
10
|
+
output.write(`\x1b[${drawn}A\x1b[0J`);
|
|
11
|
+
const lines = [
|
|
12
|
+
title,
|
|
13
|
+
...choices.map((c, i) => {
|
|
14
|
+
const mark = options.single ? (i === cursor ? "●" : "○") : state[i] ? "◉" : "○";
|
|
15
|
+
const pointer = i === cursor ? "›" : " ";
|
|
16
|
+
return `${pointer} ${mark} ${c.label}${c.hint ? ` \x1b[2m${c.hint}\x1b[22m` : ""}`;
|
|
17
|
+
}),
|
|
18
|
+
`\x1b[2m${help}\x1b[22m`,
|
|
19
|
+
];
|
|
20
|
+
output.write(`${lines.join("\n")}\n`);
|
|
21
|
+
drawn = lines.length;
|
|
22
|
+
};
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
emitKeypressEvents(input);
|
|
25
|
+
const raw = input.isRaw;
|
|
26
|
+
input.setRawMode(true);
|
|
27
|
+
input.resume();
|
|
28
|
+
output.write("\x1b[?25l");
|
|
29
|
+
const finish = (value) => {
|
|
30
|
+
input.off("keypress", onKey);
|
|
31
|
+
input.setRawMode(raw);
|
|
32
|
+
input.pause();
|
|
33
|
+
output.write("\x1b[?25h");
|
|
34
|
+
resolve(value);
|
|
35
|
+
};
|
|
36
|
+
const onKey = (text, key = {}) => {
|
|
37
|
+
if ((key.ctrl && key.name === "c") || key.name === "escape")
|
|
38
|
+
return finish(null);
|
|
39
|
+
if (key.name === "up" || text === "k")
|
|
40
|
+
cursor = (cursor - 1 + choices.length) % choices.length;
|
|
41
|
+
else if (key.name === "down" || text === "j")
|
|
42
|
+
cursor = (cursor + 1) % choices.length;
|
|
43
|
+
else if (!options.single && (key.name === "space" || text === " "))
|
|
44
|
+
state[cursor] = !state[cursor];
|
|
45
|
+
else if (!options.single && text === "a")
|
|
46
|
+
state.fill(!state.every(Boolean));
|
|
47
|
+
else if (key.name === "return" || key.name === "enter")
|
|
48
|
+
return finish(options.single ? choices.map((_, i) => i === cursor) : state);
|
|
49
|
+
render();
|
|
50
|
+
};
|
|
51
|
+
input.on("keypress", onKey);
|
|
52
|
+
render();
|
|
53
|
+
});
|
|
54
|
+
}
|
package/dist/proxy.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/report.js
CHANGED
|
@@ -60,7 +60,7 @@ export function renderReport(r, home, s, redacted = false) {
|
|
|
60
60
|
const clientList = r.clients.map((c) => CLIENT_NAMES[c]).join(", ");
|
|
61
61
|
lines.push(`Scanned ${plural(r.files.length, "file")} from ${clientList}: ${plural(r.servers, "MCP server")}.`);
|
|
62
62
|
for (const u of r.unreadable) {
|
|
63
|
-
lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}):
|
|
63
|
+
lines.push(s.dim(`Skipped ${tildify(u.path, home)} (${CLIENT_NAMES[u.client]}): ${u.error}.`));
|
|
64
64
|
}
|
|
65
65
|
lines.push("");
|
|
66
66
|
if (r.grade !== null) {
|
package/dist/secrets.js
CHANGED
|
@@ -28,9 +28,9 @@ const PATTERNS = [
|
|
|
28
28
|
[/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, "JWT"],
|
|
29
29
|
[/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, "password in connection string"],
|
|
30
30
|
];
|
|
31
|
-
const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_)/i;
|
|
32
|
-
/** Key suffixes that name configuration about a secret rather than the secret itself. */
|
|
33
|
-
const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var)$/i;
|
|
31
|
+
const SECRET_KEY = /(token|secret|passw(or)?d|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|credential|auth|bearer|session|cookie|\bpat\b|_pat$|^pat_|(^|[_-])key$)/i;
|
|
32
|
+
/** Key suffixes that name configuration about a secret, or a public half, rather than the secret itself. */
|
|
33
|
+
const NOT_SECRET_KEY = /(url|uri|endpoint|host|port|path|file|dir|type|mode|enabled|header|name|user(name)?|id|region|scope|scopes|method|env_var|_var|public[_-]?key)$/i;
|
|
34
34
|
const PLACEHOLDER = /^(<.*>|\{.*\}|\[.*\]|x{4,}|\*{3,}|\.{3,}|your[-_ ].*|change[-_ ]?me|todo|placeholder|example|redacted|none|null|undefined|true|false|test|secret|token)$/i;
|
|
35
35
|
/** True for `${VAR}`, `${env:VAR}`, `$VAR`, `%VAR%` and `{env:VAR}` references. */
|
|
36
36
|
export function isReference(value) {
|
package/dist/sources.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface ScanOptions {
|
|
|
7
7
|
env: Record<string, string | undefined>;
|
|
8
8
|
}
|
|
9
9
|
export declare const CLIENT_NAMES: Record<ClientId, string>;
|
|
10
|
+
export declare function appData({ home, platform, env }: Omit<ScanOptions, "cwd">): string;
|
|
10
11
|
/** Appends a key to a pointer, quoting keys that are not plain identifiers. */
|
|
11
12
|
export declare function pointerJoin(base: string, key: string): string;
|
|
12
13
|
/**
|
package/dist/sources.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { parseConfig } from "./parse.js";
|
|
3
|
+
import { ConfigParseError, parseConfig } from "./parse.js";
|
|
4
4
|
export const CLIENT_NAMES = {
|
|
5
5
|
"claude-code": "Claude Code",
|
|
6
6
|
"claude-desktop": "Claude Desktop",
|
|
@@ -11,7 +11,7 @@ export const CLIENT_NAMES = {
|
|
|
11
11
|
gemini: "Gemini CLI",
|
|
12
12
|
cline: "Cline",
|
|
13
13
|
};
|
|
14
|
-
function appData({ home, platform, env }) {
|
|
14
|
+
export function appData({ home, platform, env }) {
|
|
15
15
|
if (platform === "darwin")
|
|
16
16
|
return join(home, "Library", "Application Support");
|
|
17
17
|
if (platform === "win32")
|
|
@@ -149,6 +149,14 @@ function ancestorModes(path) {
|
|
|
149
149
|
dir = parent;
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
|
+
/** A fixed description of a read failure; system messages are not passed through. */
|
|
153
|
+
function readError(code) {
|
|
154
|
+
if (code === "EACCES" || code === "EPERM")
|
|
155
|
+
return "permission denied";
|
|
156
|
+
if (code === "EISDIR")
|
|
157
|
+
return "is a directory";
|
|
158
|
+
return code && /^E[A-Z]+$/.test(code) ? `could not read it (${code})` : "could not read it";
|
|
159
|
+
}
|
|
152
160
|
export function discover(opts) {
|
|
153
161
|
const result = { files: [], unreadable: [], servers: [], approvals: [] };
|
|
154
162
|
for (const c of candidates(opts)) {
|
|
@@ -161,7 +169,12 @@ export function discover(opts) {
|
|
|
161
169
|
mode = opts.platform === "win32" ? undefined : stat.mode & 0o777;
|
|
162
170
|
text = readFileSync(c.path, "utf8");
|
|
163
171
|
}
|
|
164
|
-
catch {
|
|
172
|
+
catch (error) {
|
|
173
|
+
const code = error.code;
|
|
174
|
+
// Absent files are normal; anything else means a config exists that we could not check.
|
|
175
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
176
|
+
result.unreadable.push({ client: c.client, path: c.path, error: readError(code) });
|
|
177
|
+
}
|
|
165
178
|
continue;
|
|
166
179
|
}
|
|
167
180
|
let data;
|
|
@@ -169,7 +182,9 @@ export function discover(opts) {
|
|
|
169
182
|
data = parseConfig(c.path, text);
|
|
170
183
|
}
|
|
171
184
|
catch (error) {
|
|
172
|
-
|
|
185
|
+
// parseConfig's messages are fixed text plus a position, never file contents.
|
|
186
|
+
const message = error instanceof ConfigParseError ? error.message : "could not parse it";
|
|
187
|
+
result.unreadable.push({ client: c.client, path: c.path, error: message });
|
|
173
188
|
continue;
|
|
174
189
|
}
|
|
175
190
|
const exposure = mode === undefined ? undefined : exposureOf(mode, ancestorModes(c.path));
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ClientId } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_SERVER = "https://toolstile.com";
|
|
3
|
+
export interface AgentLink {
|
|
4
|
+
/** The agent's authorization ID in Toolstile, valid in one workspace. */
|
|
5
|
+
id: string;
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
/** The Toolstile origin that issued the token; the proxy sends it nowhere else. */
|
|
8
|
+
server: string;
|
|
9
|
+
label: string;
|
|
10
|
+
/** The agent's own gateway token; only `toolstile mcp proxy` reads it. */
|
|
11
|
+
token: string;
|
|
12
|
+
}
|
|
13
|
+
/** Everything `auth` and `mcp` keep on this machine, in one owner-only file. */
|
|
14
|
+
export interface State {
|
|
15
|
+
/** The origin `auth login` last signed in to. */
|
|
16
|
+
server: string;
|
|
17
|
+
session?: {
|
|
18
|
+
token: string;
|
|
19
|
+
email: string;
|
|
20
|
+
expiresAt: string;
|
|
21
|
+
server: string;
|
|
22
|
+
};
|
|
23
|
+
workspace?: {
|
|
24
|
+
id: string;
|
|
25
|
+
slug: string;
|
|
26
|
+
name: string;
|
|
27
|
+
};
|
|
28
|
+
device?: {
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
};
|
|
32
|
+
agents: Partial<Record<ClientId, AgentLink>>;
|
|
33
|
+
}
|
|
34
|
+
export interface StateEnv {
|
|
35
|
+
home?: string;
|
|
36
|
+
env?: Record<string, string | undefined>;
|
|
37
|
+
platform?: NodeJS.Platform;
|
|
38
|
+
}
|
|
39
|
+
/** `TOOLSTILE_CONFIG_DIR`, else the platform's per-user config folder. */
|
|
40
|
+
export declare function stateDir({ home, env, platform }?: StateEnv): string;
|
|
41
|
+
export declare const statePath: (opts?: StateEnv) => string;
|
|
42
|
+
/**
|
|
43
|
+
* The saved server always wins over `TOOLSTILE_URL`, which only picks the server for a new
|
|
44
|
+
* `auth login`: an environment variable set by a project must not redirect saved tokens.
|
|
45
|
+
*/
|
|
46
|
+
export declare function loadState(opts?: StateEnv): State;
|
|
47
|
+
/** Written to a temporary file and renamed, so a crash never leaves half a file. */
|
|
48
|
+
export declare function saveState(state: State, opts?: StateEnv): void;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const DEFAULT_SERVER = "https://toolstile.com";
|
|
5
|
+
/** `TOOLSTILE_CONFIG_DIR`, else the platform's per-user config folder. */
|
|
6
|
+
export function stateDir({ home = homedir(), env = process.env, platform = process.platform } = {}) {
|
|
7
|
+
if (env.TOOLSTILE_CONFIG_DIR)
|
|
8
|
+
return env.TOOLSTILE_CONFIG_DIR;
|
|
9
|
+
if (platform === "win32")
|
|
10
|
+
return join(env.APPDATA ?? join(home, "AppData", "Roaming"), "toolstile");
|
|
11
|
+
return join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "toolstile");
|
|
12
|
+
}
|
|
13
|
+
export const statePath = (opts) => join(stateDir(opts), "credentials.json");
|
|
14
|
+
/**
|
|
15
|
+
* The saved server always wins over `TOOLSTILE_URL`, which only picks the server for a new
|
|
16
|
+
* `auth login`: an environment variable set by a project must not redirect saved tokens.
|
|
17
|
+
*/
|
|
18
|
+
export function loadState(opts) {
|
|
19
|
+
const fallback = { server: DEFAULT_SERVER, agents: {} };
|
|
20
|
+
let text;
|
|
21
|
+
try {
|
|
22
|
+
text = readFileSync(statePath(opts), "utf8");
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code === "ENOENT")
|
|
26
|
+
return fallback;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const data = JSON.parse(text);
|
|
31
|
+
return { ...fallback, ...data, agents: data.agents ?? {} };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new Error(`${statePath(opts)} is not valid JSON. Delete it and run toolstile auth login again.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Written to a temporary file and renamed, so a crash never leaves half a file. */
|
|
38
|
+
export function saveState(state, opts) {
|
|
39
|
+
const dir = stateDir(opts);
|
|
40
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
const path = statePath(opts);
|
|
42
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
43
|
+
try {
|
|
44
|
+
writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
45
|
+
chmodSync(temp, 0o600);
|
|
46
|
+
renameSync(temp, path);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
rmSync(temp, { force: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -55,7 +55,10 @@ export interface AuditResult {
|
|
|
55
55
|
client: ClientId;
|
|
56
56
|
path: string;
|
|
57
57
|
}[];
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Files that exist but could not be read or parsed. `error` is a fixed description with at most
|
|
60
|
+
* a line and column, for example `invalid TOML at line 3, column 12`; it never quotes the file.
|
|
61
|
+
*/
|
|
59
62
|
unreadable: {
|
|
60
63
|
client: ClientId;
|
|
61
64
|
path: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolstile",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Audit the MCP server configs your AI agents use for plaintext secrets, unpinned packages, auto-approved tools and duplicated setups
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
7
7
|
"model-context-protocol",
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"claude",
|
|
11
11
|
"cursor",
|
|
12
12
|
"codex",
|
|
13
|
-
"secrets"
|
|
13
|
+
"secrets",
|
|
14
|
+
"mcp-proxy",
|
|
15
|
+
"device-login"
|
|
14
16
|
],
|
|
15
17
|
"homepage": "https://github.com/toolstile/cli#readme",
|
|
16
18
|
"repository": {
|
|
@@ -37,8 +39,8 @@
|
|
|
37
39
|
"scripts": {
|
|
38
40
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
39
41
|
"typecheck": "tsc --noEmit",
|
|
40
|
-
"test": "vitest run",
|
|
41
|
-
"prepublishOnly": "pnpm typecheck && pnpm
|
|
42
|
+
"test": "pnpm build && vitest run",
|
|
43
|
+
"prepublishOnly": "pnpm typecheck && pnpm test"
|
|
42
44
|
},
|
|
43
45
|
"dependencies": {
|
|
44
46
|
"smol-toml": "^1.9.1"
|