agent-from-zero 0.35.0 → 0.35.2
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 +93 -195
- package/dist/agent.js +39 -24
- package/dist/completion.js +1 -1
- package/dist/config.js +36 -1
- package/dist/context.js +46 -6
- package/dist/ink/app.js +103 -25
- package/dist/ink/chat.js +3 -1
- package/dist/ink/commands.js +5 -2
- package/dist/ink/setup.js +15 -15
- package/dist/loop.js +34 -8
- package/dist/mcp.js +332 -16
- package/dist/skills.js +189 -41
- package/dist/ui.js +8 -1
- package/package.json +1 -2
package/dist/mcp.js
CHANGED
|
@@ -5,7 +5,7 @@ import os from "node:os"; // home directory for token persistence
|
|
|
5
5
|
import path from "node:path"; // path joining
|
|
6
6
|
import fs from "node:fs"; // token file persistence
|
|
7
7
|
import chalk from "chalk"; // status lines
|
|
8
|
-
import { CONFIG } from "./config.js"; // configured servers
|
|
8
|
+
import { CONFIG, GLOBAL_SETTINGS_PATH, PROJECT_SETTINGS_PATH, mcpConfigPath, readMcpServers } from "./config.js"; // configured servers + hot reload
|
|
9
9
|
import { registerExternalTool, unregisterExternalTool } from "./tools.js"; // expose discovered tools
|
|
10
10
|
import { emit } from "./telemetry.js"; // observability
|
|
11
11
|
// MCP (Model Context Protocol) lets the agent borrow tools from external
|
|
@@ -82,6 +82,7 @@ class StdioTransport {
|
|
|
82
82
|
});
|
|
83
83
|
this.child.stderr.on("data", () => { }); // the server's stderr is its own logging — never our stdout
|
|
84
84
|
}
|
|
85
|
+
onNotification;
|
|
85
86
|
onMessage(line) {
|
|
86
87
|
let msg;
|
|
87
88
|
try {
|
|
@@ -90,6 +91,15 @@ class StdioTransport {
|
|
|
90
91
|
catch {
|
|
91
92
|
return; // ignore anything that isn't valid JSON-RPC
|
|
92
93
|
}
|
|
94
|
+
if (msg.method) {
|
|
95
|
+
if (msg.id === undefined)
|
|
96
|
+
return this.onNotification?.(msg.method, msg.params); // e.g. tools/list_changed
|
|
97
|
+
// A server→client request. We answer ping (the spec's liveness check) and
|
|
98
|
+
// refuse the rest — an unanswered request would leave the server waiting.
|
|
99
|
+
const reply = msg.method === "ping" ? { result: {} } : { error: { code: -32601, message: `Method not found: ${msg.method}` } };
|
|
100
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, ...reply }) + "\n");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
93
103
|
const resolver = this.pending.get(msg.id);
|
|
94
104
|
if (resolver) {
|
|
95
105
|
this.pending.delete(msg.id);
|
|
@@ -141,6 +151,8 @@ class HttpTransport {
|
|
|
141
151
|
url;
|
|
142
152
|
headers;
|
|
143
153
|
sessionId; // assigned by the server on initialize, echoed on every later request
|
|
154
|
+
listening; // the standalone GET stream, if open
|
|
155
|
+
onNotification;
|
|
144
156
|
constructor(def, token) {
|
|
145
157
|
this.url = def.url;
|
|
146
158
|
this.headers = { ...def.headers };
|
|
@@ -171,17 +183,82 @@ class HttpTransport {
|
|
|
171
183
|
return null; // a notification expects no body (202 Accepted)
|
|
172
184
|
const raw = await res.text();
|
|
173
185
|
const payloads = res.headers.get("content-type")?.includes("text/event-stream") ? parseSseData(raw) : [raw];
|
|
186
|
+
let response = null;
|
|
174
187
|
for (const p of payloads) {
|
|
188
|
+
// An SSE response may carry server notifications BEFORE the response we
|
|
189
|
+
// came for (Streamable HTTP allows it) — deliver those, keep looking.
|
|
190
|
+
if (this.deliver(p))
|
|
191
|
+
continue;
|
|
175
192
|
try {
|
|
176
193
|
const msg = JSON.parse(p);
|
|
177
|
-
if (msg && (msg.result !== undefined || msg.error !== undefined))
|
|
178
|
-
|
|
194
|
+
if (!response && msg && (msg.result !== undefined || msg.error !== undefined))
|
|
195
|
+
response = msg; // the response we came for
|
|
179
196
|
}
|
|
180
197
|
catch {
|
|
181
198
|
/* skip non-JSON frames (SSE comments, keep-alives) */
|
|
182
199
|
}
|
|
183
200
|
}
|
|
184
|
-
return
|
|
201
|
+
return response;
|
|
202
|
+
}
|
|
203
|
+
// Hand one raw payload to onNotification if it is a server notification.
|
|
204
|
+
// Returns true when it was one (so the caller doesn't treat it as a response).
|
|
205
|
+
deliver(raw) {
|
|
206
|
+
try {
|
|
207
|
+
const msg = JSON.parse(raw);
|
|
208
|
+
if (!msg?.method || msg.id !== undefined)
|
|
209
|
+
return false;
|
|
210
|
+
this.onNotification?.(msg.method, msg.params);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Streamable HTTP's server→client channel: a long-lived GET that the server
|
|
218
|
+
// writes SSE frames into whenever it wants — that's where list_changed
|
|
219
|
+
// arrives between our requests. A server without one answers 405; then
|
|
220
|
+
// notifications can still ride along on POST responses (above). A dropped
|
|
221
|
+
// stream reconnects with backoff; one that keeps dying quickly gives up.
|
|
222
|
+
listen() {
|
|
223
|
+
if (this.listening)
|
|
224
|
+
return;
|
|
225
|
+
const ctrl = (this.listening = new AbortController());
|
|
226
|
+
const run = async (attempt) => {
|
|
227
|
+
const started = Date.now();
|
|
228
|
+
try {
|
|
229
|
+
const res = await fetch(this.url, {
|
|
230
|
+
method: "GET",
|
|
231
|
+
headers: { accept: "text/event-stream", ...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}), ...this.headers },
|
|
232
|
+
signal: ctrl.signal,
|
|
233
|
+
});
|
|
234
|
+
if (!res.ok || !res.body || !res.headers.get("content-type")?.includes("text/event-stream"))
|
|
235
|
+
return; // no standalone stream (405 etc.) — not an error
|
|
236
|
+
const reader = res.body.getReader();
|
|
237
|
+
const decoder = new TextDecoder();
|
|
238
|
+
let buf = "";
|
|
239
|
+
for (;;) {
|
|
240
|
+
const { value, done } = await reader.read();
|
|
241
|
+
if (done)
|
|
242
|
+
break;
|
|
243
|
+
buf += decoder.decode(value, { stream: true });
|
|
244
|
+
const frames = buf.split(/\r?\n\r?\n/);
|
|
245
|
+
buf = frames.pop() ?? ""; // an incomplete frame waits for the next chunk
|
|
246
|
+
for (const frame of frames)
|
|
247
|
+
for (const data of parseSseData(frame))
|
|
248
|
+
this.deliver(data);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
if (ctrl.signal.aborted)
|
|
253
|
+
return; // closed on purpose
|
|
254
|
+
}
|
|
255
|
+
if (ctrl.signal.aborted)
|
|
256
|
+
return;
|
|
257
|
+
const next = Date.now() - started > 30_000 ? 0 : attempt + 1; // a stream that lived a while resets the backoff
|
|
258
|
+
if (next < 5)
|
|
259
|
+
setTimeout(() => void run(next), 1000 * 2 ** next).unref(); // never keeps the process alive
|
|
260
|
+
};
|
|
261
|
+
void run(0);
|
|
185
262
|
}
|
|
186
263
|
async request(payload, timeoutMs) {
|
|
187
264
|
try {
|
|
@@ -202,6 +279,7 @@ class HttpTransport {
|
|
|
202
279
|
this.headers["authorization"] = `Bearer ${token}`; // a refreshed token; the session id stays valid
|
|
203
280
|
}
|
|
204
281
|
close() {
|
|
282
|
+
this.listening?.abort(); // stop the event stream first
|
|
205
283
|
// Best-effort: tell the server to drop the session. No await, no error care.
|
|
206
284
|
if (this.sessionId) {
|
|
207
285
|
void fetch(this.url, { method: "DELETE", headers: { "mcp-session-id": this.sessionId, ...this.headers } }).catch(() => { });
|
|
@@ -217,8 +295,15 @@ function makeTransport(def, token) {
|
|
|
217
295
|
class McpClient {
|
|
218
296
|
transport;
|
|
219
297
|
nextId = 1; // JSON-RPC request id counter
|
|
298
|
+
onToolsChanged; // set by the registry: re-fetch this server's tools
|
|
299
|
+
protocolVersion; // what the server answered in the handshake (the /mcp panel shows it)
|
|
300
|
+
capabilities = []; // e.g. ["tools", "resources"] — the top-level keys the server declared
|
|
220
301
|
constructor(def, token) {
|
|
221
302
|
this.transport = makeTransport(def, token);
|
|
303
|
+
this.transport.onNotification = (method) => {
|
|
304
|
+
if (method === "notifications/tools/list_changed")
|
|
305
|
+
this.onToolsChanged?.();
|
|
306
|
+
};
|
|
222
307
|
}
|
|
223
308
|
request(method, params, timeoutMs) {
|
|
224
309
|
return this.transport.request({ jsonrpc: "2.0", id: this.nextId++, method, params }, timeoutMs);
|
|
@@ -228,8 +313,17 @@ class McpClient {
|
|
|
228
313
|
const init = await this.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO }, INIT_TIMEOUT_MS);
|
|
229
314
|
if (init.error)
|
|
230
315
|
throw new Error(init.error.message); // server refused to initialize
|
|
316
|
+
this.protocolVersion = init.result?.protocolVersion;
|
|
317
|
+
this.capabilities = Object.keys(init.result?.capabilities ?? {});
|
|
231
318
|
this.transport.notify("notifications/initialized", {}); // tell the server we're ready
|
|
232
|
-
|
|
319
|
+
// A server that promises tools/list_changed may change its tools later —
|
|
320
|
+
// open the channel it will announce that on (HTTP's event stream).
|
|
321
|
+
if (init.result?.capabilities?.tools?.listChanged)
|
|
322
|
+
this.transport.listen?.();
|
|
323
|
+
return this.listTools(); // ask what it offers
|
|
324
|
+
}
|
|
325
|
+
async listTools() {
|
|
326
|
+
const list = await this.request("tools/list", {}, INIT_TIMEOUT_MS);
|
|
233
327
|
if (list.error)
|
|
234
328
|
throw new Error(list.error.message);
|
|
235
329
|
return list.result?.tools ?? [];
|
|
@@ -595,8 +689,10 @@ function markNeedsAuth(name, challenge) {
|
|
|
595
689
|
// Connect a single configured server and register its tools. Updates the
|
|
596
690
|
// registry with the live status. Used by connectMcpServers() at startup and by
|
|
597
691
|
// /mcp reconnect / enable / auth.
|
|
598
|
-
async function connectOne(name, retried = false) {
|
|
692
|
+
async function connectOne(name, retried = false, quiet = false) {
|
|
599
693
|
const entry = entryFor(name);
|
|
694
|
+
const attempt = {}; // this connect's identity (see the guard after the handshake)
|
|
695
|
+
entry.attempt = attempt;
|
|
600
696
|
// Unregister any stale tools from a previous connection.
|
|
601
697
|
for (const n of entry.toolNames)
|
|
602
698
|
unregisterExternalTool(n);
|
|
@@ -612,21 +708,31 @@ async function connectOne(name, retried = false) {
|
|
|
612
708
|
}
|
|
613
709
|
const client = new McpClient(entry.def, stored?.accessToken);
|
|
614
710
|
try {
|
|
711
|
+
client.onToolsChanged = () => void refreshServerTools(name, client);
|
|
615
712
|
const specs = await client.start(); // handshake + discover
|
|
713
|
+
// Connecting runs in the background now, so the world may have moved on
|
|
714
|
+
// while we waited: the user disabled it, reconnected it (a newer attempt),
|
|
715
|
+
// or a settings reload removed it. Then this result is stale — drop it.
|
|
716
|
+
if (registry.get(name) !== entry || entry.attempt !== attempt || entry.status === "disabled")
|
|
717
|
+
return client.kill();
|
|
616
718
|
entry.client = client;
|
|
617
719
|
entry.tools = registerTools(name, specs, client);
|
|
618
720
|
entry.toolNames = specs.map((s) => `mcp__${name}__${s.name}`);
|
|
721
|
+
entry.toolSpecs = specs;
|
|
619
722
|
entry.status = "connected";
|
|
620
723
|
entry.error = undefined;
|
|
621
724
|
emit("agent_mcp_connected", { server: name, tools: specs.length, transport: entry.def.url ? "http" : "stdio" });
|
|
622
|
-
|
|
725
|
+
if (!quiet)
|
|
726
|
+
console.log(chalk.dim(`(mcp: ${name} — ${specs.length} tools${entry.def.url ? " over http" : ""})`)); // visible on reconnect; a background startup stays silent (the status bar reports problems)
|
|
623
727
|
}
|
|
624
728
|
catch (err) {
|
|
625
729
|
client.kill(); // don't leak the process/session
|
|
730
|
+
if (registry.get(name) !== entry || entry.attempt !== attempt || entry.status === "disabled")
|
|
731
|
+
return; // stale attempt — its failure no longer matters
|
|
626
732
|
if (err instanceof NeedsAuthError) {
|
|
627
733
|
// A stale token (expired early, revoked): one refresh + retry before giving up.
|
|
628
734
|
if (!retried && stored?.refreshToken && (await refreshAccessToken(name)))
|
|
629
|
-
return connectOne(name, true);
|
|
735
|
+
return connectOne(name, true, quiet);
|
|
630
736
|
markNeedsAuth(name, err);
|
|
631
737
|
}
|
|
632
738
|
else {
|
|
@@ -634,22 +740,167 @@ async function connectOne(name, retried = false) {
|
|
|
634
740
|
entry.error = err.message;
|
|
635
741
|
}
|
|
636
742
|
emit("agent_mcp_failed", { server: name });
|
|
637
|
-
|
|
743
|
+
if (!quiet)
|
|
744
|
+
console.error(chalk.yellow(` [mcp] ${name} ${entry.status === "needs-auth" ? `needs authentication (run /mcp auth ${name})` : "failed to start"}: ${err.message}`));
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// The server said its tools changed (notifications/tools/list_changed): ask
|
|
748
|
+
// again and swap the set in place — drop every mcp__<server>__ tool, register
|
|
749
|
+
// the fresh ones, the same swap Claude Code does. The next model call sees the
|
|
750
|
+
// new list (toolDefinitions() is rebuilt per call). A burst of notifications
|
|
751
|
+
// coalesces: one refresh runs, and at most one more follows it.
|
|
752
|
+
const refreshing = new Map();
|
|
753
|
+
async function refreshServerTools(name, client) {
|
|
754
|
+
const running = refreshing.get(name);
|
|
755
|
+
if (running)
|
|
756
|
+
return void (running.again = true); // already fetching — fetch once more when done
|
|
757
|
+
const state = { again: false };
|
|
758
|
+
refreshing.set(name, state);
|
|
759
|
+
try {
|
|
760
|
+
do {
|
|
761
|
+
state.again = false;
|
|
762
|
+
const entry = registry.get(name);
|
|
763
|
+
if (!entry || entry.client !== client)
|
|
764
|
+
return; // reconnected, disabled or removed since — this client is stale
|
|
765
|
+
let specs;
|
|
766
|
+
try {
|
|
767
|
+
specs = await client.listTools();
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
return; // keep the tools we have; the next notification or a reconnect will retry
|
|
771
|
+
}
|
|
772
|
+
if (entry.client !== client)
|
|
773
|
+
return;
|
|
774
|
+
for (const n of entry.toolNames)
|
|
775
|
+
unregisterExternalTool(n);
|
|
776
|
+
entry.tools = registerTools(name, specs, client);
|
|
777
|
+
entry.toolNames = specs.map((s) => `mcp__${name}__${s.name}`);
|
|
778
|
+
entry.toolSpecs = specs;
|
|
779
|
+
emit("agent_mcp_tools_changed", { server: name, tools: specs.length });
|
|
780
|
+
console.log(chalk.dim(`(mcp: ${name} tools changed — ${specs.length} tools)`));
|
|
781
|
+
} while (state.again);
|
|
782
|
+
}
|
|
783
|
+
finally {
|
|
784
|
+
refreshing.delete(name);
|
|
638
785
|
}
|
|
639
786
|
}
|
|
640
787
|
// Connect to every configured MCP server, register their tools, and return a
|
|
641
788
|
// cleanup function. Failures are isolated: a server that won't start is logged
|
|
642
789
|
// and skipped — it never stops the agent or the other servers.
|
|
643
|
-
|
|
790
|
+
//
|
|
791
|
+
// Two modes. The interactive UI passes background: true — the Claude Code way:
|
|
792
|
+
// every server is marked "pending" and connected in PARALLEL without blocking
|
|
793
|
+
// startup; the prompt is usable at once, each server's tools appear the moment
|
|
794
|
+
// it connects, and a failure shows up in the status bar instead of a log line.
|
|
795
|
+
// Print mode (-p) and the readline REPL await everything first: a one-shot
|
|
796
|
+
// task must see all its tools on the very first model call.
|
|
797
|
+
export async function connectMcpServers(options = {}) {
|
|
644
798
|
const entries = Object.keys(CONFIG.mcpServers); // configured server names
|
|
645
|
-
|
|
646
|
-
|
|
799
|
+
if (options.background) {
|
|
800
|
+
for (const name of entries)
|
|
801
|
+
entryFor(name).status = "pending";
|
|
802
|
+
for (const name of entries)
|
|
803
|
+
void connectOne(name, false, true); // fire and forget; connectOne never throws
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
for (const name of entries)
|
|
807
|
+
await connectOne(name); // sequential: a slow start doesn't starve the next one
|
|
647
808
|
}
|
|
648
809
|
return () => {
|
|
649
810
|
for (const entry of registry.values())
|
|
650
811
|
entry.client?.kill(); // cleanup on exit
|
|
651
812
|
};
|
|
652
813
|
}
|
|
814
|
+
// ---- hot reload: pick up settings.json edits without a restart ----------------
|
|
815
|
+
// Adding a server used to mean quitting and relaunching. Now a settings change
|
|
816
|
+
// is DIFFED against what is running and only the difference is touched:
|
|
817
|
+
// added → connect it and register its tools
|
|
818
|
+
// removed → kill it and unregister its tools
|
|
819
|
+
// changed → reconnect it with the new definition
|
|
820
|
+
// same → left alone (an unrelated settings edit reconnects nothing)
|
|
821
|
+
// The tool list sent to the model is rebuilt every turn (toolDefinitions()), so
|
|
822
|
+
// the next model call simply sees the new set. A tool call already in flight to
|
|
823
|
+
// a server being removed fails with a readable error, like any dropped server.
|
|
824
|
+
//
|
|
825
|
+
// Claude Code swaps MCP tools the same way (drop every mcp__<server>__ tool,
|
|
826
|
+
// append the fresh ones), but it only re-reads MCP config on explicit triggers
|
|
827
|
+
// like /reload-plugins. We also watch the files, so a save is enough.
|
|
828
|
+
// Key-order-independent JSON, so reformatting an entry doesn't count as a change.
|
|
829
|
+
const stableJson = (v) => JSON.stringify(v, (_k, val) => (val && typeof val === "object" && !Array.isArray(val) ? Object.fromEntries(Object.entries(val).sort(([a], [b]) => a.localeCompare(b))) : val));
|
|
830
|
+
// Reloads run one at a time: a second save while the first reload is still
|
|
831
|
+
// connecting queues behind it instead of racing it over the same registry.
|
|
832
|
+
let reloadChain = Promise.resolve();
|
|
833
|
+
// Apply a new mcpServers map. `next` is the test seam; by default both settings
|
|
834
|
+
// files are re-read. Returns one human-readable line per change ([] = nothing
|
|
835
|
+
// changed), or a single warning line when the files can't be parsed right now.
|
|
836
|
+
export function reloadMcpServers(next) {
|
|
837
|
+
const run = async () => {
|
|
838
|
+
const servers = next ?? readMcpServers();
|
|
839
|
+
if (!servers)
|
|
840
|
+
return ["settings.json is not valid JSON right now — MCP servers left unchanged"];
|
|
841
|
+
const previous = CONFIG.mcpServers;
|
|
842
|
+
CONFIG.mcpServers = servers; // entryFor / listMcpServers read the new map from here on
|
|
843
|
+
const changes = [];
|
|
844
|
+
for (const name of Object.keys(previous)) {
|
|
845
|
+
if (name in servers)
|
|
846
|
+
continue;
|
|
847
|
+
const entry = registry.get(name);
|
|
848
|
+
if (entry) {
|
|
849
|
+
for (const n of entry.toolNames)
|
|
850
|
+
unregisterExternalTool(n);
|
|
851
|
+
entry.client?.kill();
|
|
852
|
+
registry.delete(name); // gone from the config → gone from /mcp too
|
|
853
|
+
}
|
|
854
|
+
changes.push(`removed ${name}`);
|
|
855
|
+
}
|
|
856
|
+
for (const [name, def] of Object.entries(servers)) {
|
|
857
|
+
const old = previous[name];
|
|
858
|
+
if (old && stableJson(old) === stableJson(def))
|
|
859
|
+
continue; // untouched
|
|
860
|
+
const entry = registry.get(name);
|
|
861
|
+
if (entry)
|
|
862
|
+
entry.def = def; // entryFor only builds new entries, so swap the def by hand
|
|
863
|
+
if (entry?.status === "disabled") {
|
|
864
|
+
changes.push(`updated ${name} (still disabled)`); // the user turned it off this session — respect that
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
await connectOne(name); // replaces any stale client + tools, logs its own status line
|
|
868
|
+
changes.push(`${old ? "reconnected" : "added"} ${name} (${registry.get(name)?.status})`);
|
|
869
|
+
}
|
|
870
|
+
return changes;
|
|
871
|
+
};
|
|
872
|
+
const result = reloadChain.then(run, run);
|
|
873
|
+
reloadChain = result.catch(() => { }); // a failed reload must not wedge the ones after it
|
|
874
|
+
return result;
|
|
875
|
+
}
|
|
876
|
+
// Watch both settings files and hot-reload on save. Polling (fs.watchFile), not
|
|
877
|
+
// fs.watch: editors often save by writing a temp file and renaming it over the
|
|
878
|
+
// original, which silently detaches an fs.watch handle; a stat poll just sees a
|
|
879
|
+
// new mtime. It also works for a file that doesn't exist yet — create
|
|
880
|
+
// .mini-agent/settings.json mid-session and it is picked up. Returns a stop function.
|
|
881
|
+
const WATCH_INTERVAL_MS = 1000; // how often to stat; Claude Code polls ~/.claude.json at the same rate
|
|
882
|
+
const RELOAD_DEBOUNCE_MS = 300; // a burst of writes (save + format-on-save) → one reload
|
|
883
|
+
export function watchMcpConfig() {
|
|
884
|
+
let timer;
|
|
885
|
+
const onChange = (curr, prev) => {
|
|
886
|
+
if (curr.mtimeMs === prev.mtimeMs)
|
|
887
|
+
return; // a stat tick with nothing new
|
|
888
|
+
clearTimeout(timer);
|
|
889
|
+
timer = setTimeout(async () => {
|
|
890
|
+
const changes = await reloadMcpServers().catch((err) => [`reload failed: ${err.message}`]);
|
|
891
|
+
if (changes.length)
|
|
892
|
+
console.log(chalk.dim(`(mcp config changed: ${changes.join(", ")})`));
|
|
893
|
+
}, RELOAD_DEBOUNCE_MS);
|
|
894
|
+
};
|
|
895
|
+
const files = [GLOBAL_SETTINGS_PATH, PROJECT_SETTINGS_PATH];
|
|
896
|
+
for (const file of files)
|
|
897
|
+
fs.watchFile(file, { interval: WATCH_INTERVAL_MS, persistent: false }, onChange); // persistent:false — never keeps the process alive
|
|
898
|
+
return () => {
|
|
899
|
+
clearTimeout(timer);
|
|
900
|
+
for (const file of files)
|
|
901
|
+
fs.unwatchFile(file, onChange);
|
|
902
|
+
};
|
|
903
|
+
}
|
|
653
904
|
// ---- /mcp command support ----------------------------------------------------
|
|
654
905
|
export function listMcpServers() {
|
|
655
906
|
// Include every CONFIGURED server, even ones never attempted this session.
|
|
@@ -658,29 +909,70 @@ export function listMcpServers() {
|
|
|
658
909
|
return {
|
|
659
910
|
name,
|
|
660
911
|
transport: def.url ? "http" : "stdio",
|
|
661
|
-
status: entry?.status ?? "
|
|
912
|
+
status: entry?.status ?? "pending", // configured but not attempted yet (e.g. just added to settings)
|
|
662
913
|
tools: entry?.tools ?? 0,
|
|
663
914
|
error: entry?.error,
|
|
664
915
|
authenticated: Boolean(oauthStore[name]?.accessToken),
|
|
665
916
|
url: def.url,
|
|
917
|
+
command: def.command ? [def.command, ...(def.args ?? [])].join(" ") : undefined,
|
|
918
|
+
protocolVersion: entry?.status === "connected" ? entry.client?.protocolVersion : undefined,
|
|
919
|
+
capabilities: entry?.status === "connected" ? entry.client?.capabilities ?? [] : [],
|
|
920
|
+
toolSpecs: (entry?.toolSpecs ?? []).map((t) => ({ name: t.name, description: t.description })),
|
|
666
921
|
};
|
|
667
922
|
});
|
|
668
923
|
}
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
//
|
|
924
|
+
// /mcp <subcommand> <server> → the menu action it runs (scriptable, like Claude Code's /mcp).
|
|
925
|
+
export const MCP_SUBCOMMANDS = { tools: "view-tools", auth: "authenticate", authenticate: "authenticate", "clear-auth": "clear-auth", reconnect: "reconnect", enable: "enable", disable: "disable" };
|
|
926
|
+
// Which actions make sense for a server in a given state — Claude Code's menu,
|
|
927
|
+
// in its order: View tools, (Re-)authenticate, Clear authentication,
|
|
928
|
+
// Reconnect, Disable. A disabled server offers only Enable.
|
|
672
929
|
export function mcpActionsFor(info) {
|
|
673
930
|
if (info.status === "disabled") {
|
|
674
931
|
return [{ label: "Enable", action: "enable" }];
|
|
675
932
|
}
|
|
676
933
|
const out = [];
|
|
934
|
+
if (info.status === "connected" && info.tools > 0)
|
|
935
|
+
out.push({ label: "View tools", action: "view-tools" });
|
|
677
936
|
if (info.transport === "http") {
|
|
678
937
|
out.push({ label: info.authenticated ? "Re-authenticate" : "Authenticate", action: "authenticate" });
|
|
938
|
+
if (info.authenticated)
|
|
939
|
+
out.push({ label: "Clear authentication", action: "clear-auth" });
|
|
679
940
|
}
|
|
680
941
|
out.push({ label: "Reconnect", action: "reconnect" });
|
|
681
942
|
out.push({ label: "Disable", action: "disable" });
|
|
682
943
|
return out;
|
|
683
944
|
}
|
|
945
|
+
// The server's details for the /mcp panel, one "Label: value" per line —
|
|
946
|
+
// the same fields Claude Code's server screen shows.
|
|
947
|
+
export function mcpServerDetails(info) {
|
|
948
|
+
const ok = (s) => chalk.green(`✔ ${s}`);
|
|
949
|
+
const status = { connected: ok("connected"), pending: chalk.dim("◌ connecting…"), "needs-auth": chalk.yellow("△ needs authentication"), failed: chalk.red("✗ failed"), disabled: chalk.dim("○ disabled") }[info.status];
|
|
950
|
+
const rows = [["Status", status + (info.status === "failed" && info.error ? chalk.dim(` ${info.error}`) : "")]];
|
|
951
|
+
if (info.transport === "http")
|
|
952
|
+
rows.push(["Auth", info.authenticated ? ok("authenticated") : chalk.dim("not authenticated")]);
|
|
953
|
+
if (info.protocolVersion)
|
|
954
|
+
rows.push(["Protocol", chalk.dim(info.protocolVersion)]);
|
|
955
|
+
rows.push(info.url ? ["URL", chalk.dim(info.url)] : ["Command", chalk.dim(info.command ?? "")]);
|
|
956
|
+
rows.push(["Config location", chalk.dim(mcpConfigPath(info.name))]);
|
|
957
|
+
if (info.capabilities.length)
|
|
958
|
+
rows.push(["Capabilities", info.capabilities.join(" · ")]);
|
|
959
|
+
if (info.status === "connected")
|
|
960
|
+
rows.push(["Tools", chalk.dim(`${info.tools} tool${info.tools === 1 ? "" : "s"}`)]);
|
|
961
|
+
const width = Math.max(...rows.map(([k]) => k.length)) + 3;
|
|
962
|
+
return rows.map(([k, v]) => chalk.bold(`${k}:`.padEnd(width)) + v).join("\n");
|
|
963
|
+
}
|
|
964
|
+
// What the status bar should flag: servers that failed or need auth, and how
|
|
965
|
+
// many are still connecting. Claude Code shows the same in its footer.
|
|
966
|
+
// Only servers we actually tried count (the registry), so a session that never
|
|
967
|
+
// connects MCP (tests, a front-end without it) shows nothing.
|
|
968
|
+
export function mcpProblems() {
|
|
969
|
+
const live = [...registry.entries()].filter(([name]) => name in CONFIG.mcpServers);
|
|
970
|
+
return {
|
|
971
|
+
failed: live.filter(([, e]) => e.status === "failed").map(([name]) => name),
|
|
972
|
+
needsAuth: live.filter(([, e]) => e.status === "needs-auth").map(([name]) => name),
|
|
973
|
+
pending: live.filter(([, e]) => e.status === "pending").length,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
684
976
|
// Trade the stored refresh token for a new access token, at the token endpoint
|
|
685
977
|
// recorded when the user authenticated — no rediscovery. A definitive rejection
|
|
686
978
|
// (400/401: the refresh token itself is dead) wipes the record so the UI tells
|
|
@@ -744,6 +1036,26 @@ export async function authenticateMcpServer(name, onUrl) {
|
|
|
744
1036
|
? `Authentication successful. Connected to ${name}.`
|
|
745
1037
|
: `Authentication done, but ${name} is ${status} — use /mcp reconnect ${name} to retry.`;
|
|
746
1038
|
}
|
|
1039
|
+
// Forget a server's stored OAuth tokens, then reconnect — without a token it
|
|
1040
|
+
// comes back as "needs auth" (or connected, if its config carries a static
|
|
1041
|
+
// header). The next /mcp auth starts a fresh browser flow.
|
|
1042
|
+
export async function clearMcpAuthentication(name) {
|
|
1043
|
+
if (!CONFIG.mcpServers[name])
|
|
1044
|
+
return `No MCP server named "${name}"`;
|
|
1045
|
+
delete oauthStore[name];
|
|
1046
|
+
writeOAuthStore(oauthStore);
|
|
1047
|
+
await connectOne(name, false, true);
|
|
1048
|
+
return `Cleared authentication for ${name}${entryFor(name).status === "needs-auth" ? ` — run /mcp auth ${name} to sign in again` : ""}.`;
|
|
1049
|
+
}
|
|
1050
|
+
// The tool list as text (name + the first line of its description).
|
|
1051
|
+
export function describeMcpTools(name) {
|
|
1052
|
+
const info = listMcpServers().find((s) => s.name === name);
|
|
1053
|
+
if (!info)
|
|
1054
|
+
return `No MCP server named "${name}"`;
|
|
1055
|
+
if (!info.toolSpecs.length)
|
|
1056
|
+
return `${name} has no tools${info.status === "connected" ? "" : ` (${info.status})`}.`;
|
|
1057
|
+
return [`${name} — ${info.toolSpecs.length} tool${info.toolSpecs.length === 1 ? "" : "s"}:`, ...info.toolSpecs.map((t) => ` ${chalk.bold(t.name)}${t.description ? chalk.dim(` ${t.description.split("\n")[0].slice(0, 100)}`) : ""}`)].join("\n");
|
|
1058
|
+
}
|
|
747
1059
|
// Disconnect and reconnect a single server (re-registering its tools). A stale
|
|
748
1060
|
// stored token is refreshed inside connectOne, so this is just: connect again.
|
|
749
1061
|
export async function reconnectMcpServer(name) {
|
|
@@ -779,6 +1091,10 @@ export async function enableMcpServer(name) {
|
|
|
779
1091
|
// Run one action from mcpActionsFor and return the note to display.
|
|
780
1092
|
export async function runMcpAction(name, action, onUrl) {
|
|
781
1093
|
switch (action) {
|
|
1094
|
+
case "view-tools":
|
|
1095
|
+
return describeMcpTools(name);
|
|
1096
|
+
case "clear-auth":
|
|
1097
|
+
return clearMcpAuthentication(name);
|
|
782
1098
|
case "authenticate":
|
|
783
1099
|
return authenticateMcpServer(name, onUrl);
|
|
784
1100
|
case "reconnect":
|