roforge-cli 0.3.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.
@@ -0,0 +1,154 @@
1
+ // Local Studio bridge: a tiny HTTP server on 127.0.0.1 that the RoForge
2
+ // Bridge plugin (installed in Studio) polls for commands.
3
+ //
4
+ // Protocol (see docs/BRIDGE.md):
5
+ // GET /v1/bridge/ping → {ok, time}
6
+ // GET /v1/bridge/jobs → {jobs: [ ...pending ]} (first job is claimed)
7
+ // POST /v1/bridge/jobs/:id/result → {ok}
8
+ // Auth: Authorization: Bearer <bridge token> (loopback-only by design).
9
+ import http from "node:http";
10
+ import { json } from "./wire.js";
11
+
12
+ export class BridgeServer {
13
+ constructor({ port, host = "127.0.0.1", token, jobTimeoutMs = 60000 }) {
14
+ this.port = port;
15
+ this.host = host;
16
+ this.token = token;
17
+ this.jobTimeoutMs = jobTimeoutMs;
18
+ this.jobs = new Map(); // id → {id, tool, args, status, resolve, timer}
19
+ this.lastPingAt = null;
20
+ this.lastSeenAt = null;
21
+ this.nextJobId = 1;
22
+ this.server = null;
23
+ this.onEvent = null; // (kind, payload) hook for UI
24
+ }
25
+
26
+ get connected() {
27
+ return this.lastSeenAt !== null && Date.now() - this.lastSeenAt < 10_000;
28
+ }
29
+ get lastSeen() {
30
+ return this.lastSeenAt;
31
+ }
32
+
33
+ start() {
34
+ this.server = http.createServer((req, res) => this._handle(req, res));
35
+ return new Promise((resolve, reject) => {
36
+ this.server.once("error", reject);
37
+ this.server.listen(this.port, this.host, () => {
38
+ const { port } = this.server.address();
39
+ this.port = port;
40
+ resolve(this);
41
+ });
42
+ });
43
+ }
44
+
45
+ stop() {
46
+ for (const job of this.jobs.values()) {
47
+ clearTimeout(job.timer);
48
+ job.resolve({ ok: false, error: "bridge shut down" });
49
+ }
50
+ this.jobs.clear();
51
+ if (this.server) {
52
+ const s = this.server;
53
+ this.server = null;
54
+ s.close();
55
+ }
56
+ }
57
+
58
+ _authorized(req) {
59
+ const h = String(req.headers["authorization"] || "");
60
+ const m = /^Bearer\s+(.+)$/i.exec(h);
61
+ return Boolean(m && m[1].trim() === this.token);
62
+ }
63
+
64
+ _handle(req, res) {
65
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
66
+ const path = url.pathname.replace(/\/+$/, "") || "/";
67
+ const emit = (kind, payload) => this.onEvent && this.onEvent(kind, payload);
68
+
69
+ try {
70
+ if (req.method === "GET" && path === "/health") {
71
+ return json(res, 200, { ok: true, service: "roforge-bridge", connected: this.connected, port: this.port });
72
+ }
73
+ if (!this._authorized(req)) {
74
+ return json(res, 401, { error: "unauthorized — use the token printed by roforge (or `roforge studio`)", code: "UNAUTHORIZED" });
75
+ }
76
+ this.lastSeenAt = Date.now();
77
+
78
+ if (req.method === "GET" && path === "/v1/bridge/ping") {
79
+ this.lastPingAt = Date.now();
80
+ emit("connected", this.lastPingAt);
81
+ return json(res, 200, { ok: true, time: new Date().toISOString(), jobsPending: this.jobs.size });
82
+ }
83
+
84
+ if (req.method === "GET" && path === "/v1/bridge/jobs") {
85
+ // claim the first pending job (FIFO)
86
+ for (const job of this.jobs.values()) {
87
+ if (job.status === "pending") {
88
+ job.status = "claimed";
89
+ job.claimedAt = Date.now();
90
+ emit("job_claimed", job);
91
+ return json(res, 200, { jobs: [{ id: job.id, tool: job.tool, args: job.args }] });
92
+ }
93
+ }
94
+ return json(res, 200, { jobs: [] });
95
+ }
96
+
97
+ const m = /^\/v1\/bridge\/jobs\/(\w+)\/result$/.exec(path);
98
+ if (req.method === "POST" && m) {
99
+ const job = this.jobs.get(m[1]);
100
+ if (!job) return json(res, 404, { error: "unknown job", code: "JOB_NOT_FOUND" });
101
+ let body = {};
102
+ const chunks = [];
103
+ let size = 0;
104
+ req.on("data", (c) => {
105
+ size += c.length;
106
+ // 16MB cap: a 1024x576 viewport PNG is ~3MB in base64 inside JSON.
107
+ if (size < 16 * 1024 * 1024) chunks.push(c);
108
+ });
109
+ req.on("end", () => {
110
+ try {
111
+ body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
112
+ } catch {
113
+ body = { error: "bad json" };
114
+ }
115
+ clearTimeout(job.timer);
116
+ const out = body.error ? { ok: false, error: body.error } : { ok: true, result: body.result ?? "" };
117
+ this.jobs.delete(job.id);
118
+ job.resolve(out);
119
+ emit("job_done", { id: job.id, ...out });
120
+ json(res, 200, { ok: true });
121
+ });
122
+ return;
123
+ }
124
+
125
+ return json(res, 404, { error: `no route: ${req.method} ${path}`, code: "NOT_FOUND" });
126
+ } catch (e) {
127
+ json(res, 500, { error: e.message, code: "INTERNAL" });
128
+ }
129
+ }
130
+
131
+ // Submit a tool job and wait for the plugin's result. Resolves to
132
+ // {ok:true, result:string} or {ok:false, error:string}.
133
+ submit(tool, args, { timeoutMs = this.jobTimeoutMs } = {}) {
134
+ return new Promise((resolve) => {
135
+ if (!this.connected) {
136
+ return resolve({
137
+ ok: false,
138
+ error:
139
+ "Studio bridge is not connected. Open Roblox Studio with the RoForge Bridge plugin active " +
140
+ `(bridge: http://${this.host}:${this.port}).`,
141
+ });
142
+ }
143
+ const id = `job_${this.nextJobId++}`;
144
+ const job = { id, tool, args, status: "pending", resolve, claimedAt: null, timer: null };
145
+ job.timer = setTimeout(() => {
146
+ if (this.jobs.get(id) === job) {
147
+ this.jobs.delete(id);
148
+ job.resolve({ ok: false, error: `studio job timed out after ${Math.round(timeoutMs / 1000)}s` });
149
+ }
150
+ }, timeoutMs);
151
+ this.jobs.set(id, job);
152
+ });
153
+ }
154
+ }
@@ -0,0 +1,10 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ export function json(res, status, obj) {
4
+ const body = JSON.stringify(obj);
5
+ res.writeHead(status, {
6
+ "content-type": "application/json; charset=utf-8",
7
+ "content-length": Buffer.byteLength(body),
8
+ });
9
+ res.end(body);
10
+ }
package/src/config.js ADDED
@@ -0,0 +1,227 @@
1
+ // RoForge CLI configuration.
2
+ // Precedence: env vars > ~/.roforge/config.json > defaults.
3
+ //
4
+ // Privacy: API keys live ONLY in this local config (or env). They are sent
5
+ // ONLY to the model provider. The Studio bridge and MCP traffic are local-only.
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { randomToken } from "./util.js";
10
+
11
+ export const CONFIG_DIR = process.env.ROFORGE_CONFIG_DIR || path.join(os.homedir(), ".roforge");
12
+ export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
13
+
14
+ // Provider registry. "auto" (default) picks the first configured provider,
15
+ // free-tier providers first (gemini → groq → openrouter → anthropic → openai).
16
+ export const PROVIDERS = {
17
+ gemini: {
18
+ label: "Google Gemini",
19
+ keyField: "geminiKey",
20
+ env: "GEMINI_API_KEY",
21
+ baseField: "geminiBaseUrl",
22
+ defaultModel: "gemini-2.5-flash",
23
+ freeModel: "gemini-2.5-flash", // free tier: ~1,500 req/day, no card
24
+ hasFreeTier: true,
25
+ },
26
+ groq: {
27
+ label: "Groq",
28
+ keyField: "groqKey",
29
+ env: "GROQ_API_KEY",
30
+ baseField: "groqBaseUrl",
31
+ defaultModel: "llama-3.3-70b-versatile",
32
+ freeModel: "llama-3.3-70b-versatile", // free tier: ~1,000 req/day per model
33
+ hasFreeTier: true,
34
+ },
35
+ openrouter: {
36
+ label: "OpenRouter",
37
+ keyField: "openrouterKey",
38
+ env: "OPENROUTER_API_KEY",
39
+ baseField: "openrouterBaseUrl",
40
+ defaultModel: "qwen/qwen3-coder",
41
+ freeModel: "qwen/qwen3-coder:free", // :free models: req/day limit, no billing
42
+ hasFreeTier: true,
43
+ },
44
+ anthropic: {
45
+ label: "Anthropic",
46
+ keyField: "anthropicKey",
47
+ env: "ANTHROPIC_API_KEY",
48
+ baseField: "anthropicBaseUrl",
49
+ defaultModel: "claude-sonnet-4-5",
50
+ freeModel: null,
51
+ hasFreeTier: false,
52
+ },
53
+ openai: {
54
+ label: "OpenAI",
55
+ keyField: "openaiKey",
56
+ env: "OPENAI_API_KEY",
57
+ baseField: "openaiBaseUrl",
58
+ defaultModel: "gpt-4.1",
59
+ freeModel: null,
60
+ hasFreeTier: false,
61
+ },
62
+ };
63
+
64
+ // auto-routing order. freeFirst (default) prefers the free tiers so the CLI
65
+ // works out of the box with a free AI Studio key.
66
+ const ORDER_FREE_FIRST = ["gemini", "groq", "openrouter", "anthropic", "openai"];
67
+ const ORDER_PAID_FIRST = ["anthropic", "openai", "gemini", "groq", "openrouter"];
68
+
69
+ const DEFAULTS = {
70
+ provider: "auto", // auto | gemini | groq | openrouter | anthropic | openai
71
+ model: "", // empty = provider default; "provider:model" pins a provider
72
+ freeFirst: true, // auto mode: prefer free-tier providers/models
73
+ geminiKey: "",
74
+ groqKey: "",
75
+ openrouterKey: "",
76
+ anthropicKey: "",
77
+ openaiKey: "",
78
+ geminiBaseUrl: "https://generativelanguage.googleapis.com",
79
+ groqBaseUrl: "https://api.groq.com/openai",
80
+ openrouterBaseUrl: "https://openrouter.ai/api",
81
+ anthropicBaseUrl: "https://api.anthropic.com",
82
+ openaiBaseUrl: "https://api.openai.com",
83
+ maxTokens: 8000,
84
+ maxIterations: 12,
85
+ // web search
86
+ searchProvider: "auto", // auto | serper | brave | wikipedia
87
+ serperKey: "",
88
+ braveKey: "",
89
+ // studio connection
90
+ studioMode: "auto", // auto | mcp | bridge
91
+ mcpUrl: "http://localhost:3004/mcp",
92
+ bridge: {
93
+ port: 8790,
94
+ host: "127.0.0.1",
95
+ token: "",
96
+ },
97
+ // approvals: "ask" (default) or "yolo"
98
+ approve: "ask",
99
+ // approximate pricing per 1M tokens (USD) for the cost display.
100
+ // Free-tier models are 0/0.
101
+ pricing: {
102
+ "claude-sonnet-4-5": { input: 3, output: 15 },
103
+ "claude-opus-4-5": { input: 5, output: 25 },
104
+ "claude-haiku-4-5": { input: 1, output: 5 },
105
+ "gpt-4.1": { input: 2, output: 8 },
106
+ "gpt-4o": { input: 2.5, output: 10 },
107
+ "gemini-2.5-flash": { input: 0, output: 0 },
108
+ "llama-3.3-70b-versatile": { input: 0, output: 0 },
109
+ "qwen/qwen3-coder:free": { input: 0, output: 0 },
110
+ },
111
+ };
112
+
113
+ export function loadFileConfig() {
114
+ try {
115
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
116
+ } catch {
117
+ return {};
118
+ }
119
+ }
120
+
121
+ export function saveFileConfig(patch) {
122
+ const current = loadFileConfig();
123
+ const next = deepMerge(current, patch);
124
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
125
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2));
126
+ return next;
127
+ }
128
+
129
+ function deepMerge(a, b) {
130
+ const out = { ...a };
131
+ for (const [k, v] of Object.entries(b)) {
132
+ out[k] = v && typeof v === "object" && !Array.isArray(v) && a[k] && typeof a[k] === "object" ? deepMerge(a[k], v) : v;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ // Resolved, runtime config (env overrides applied, defaults filled).
138
+ export function resolveConfig() {
139
+ const file = loadFileConfig();
140
+ const cfg = deepMerge(DEFAULTS, file);
141
+
142
+ cfg.geminiKey = process.env.GEMINI_API_KEY || cfg.geminiKey || "";
143
+ cfg.groqKey = process.env.GROQ_API_KEY || cfg.groqKey || "";
144
+ cfg.openrouterKey = process.env.OPENROUTER_API_KEY || cfg.openrouterKey || "";
145
+ cfg.anthropicKey = process.env.ANTHROPIC_API_KEY || cfg.anthropicKey || "";
146
+ cfg.openaiKey = process.env.OPENAI_API_KEY || cfg.openaiKey || "";
147
+ cfg.model = process.env.ROFORGE_MODEL || cfg.model;
148
+ if (process.env.ROFORGE_PROVIDER) cfg.provider = process.env.ROFORGE_PROVIDER;
149
+ if (process.env.ROFORGE_MCP_URL) cfg.mcpUrl = process.env.ROFORGE_MCP_URL;
150
+ if (process.env.ROFORGE_BRIDGE_PORT) cfg.bridge.port = Number(process.env.ROFORGE_BRIDGE_PORT);
151
+ if (process.env.ROFORGE_STUDIO_MODE) cfg.studioMode = process.env.ROFORGE_STUDIO_MODE;
152
+ if (process.env.ROFORGE_MAX_ITERATIONS) cfg.maxIterations = Number(process.env.ROFORGE_MAX_ITERATIONS);
153
+ if (process.env.ROFORGE_FREE_FIRST === "0" || process.env.ROFORGE_FREE_FIRST === "false") cfg.freeFirst = false;
154
+
155
+ if (!cfg.bridge.token) {
156
+ cfg.bridge.token = randomToken(24);
157
+ // persist so the token survives restarts (the plugin stores it once)
158
+ try {
159
+ saveFileConfig({ bridge: { token: cfg.bridge.token } });
160
+ } catch {
161
+ /* non-fatal */
162
+ }
163
+ }
164
+ return cfg;
165
+ }
166
+
167
+ export function providerMeta(name) {
168
+ return PROVIDERS[name] || null;
169
+ }
170
+
171
+ export function providerHasKey(cfg, name) {
172
+ const meta = PROVIDERS[name];
173
+ return Boolean(meta && cfg[meta.keyField]);
174
+ }
175
+
176
+ // The provider that will actually serve requests:
177
+ // an explicit (valid, key-held) cfg.provider wins; otherwise "auto" walks
178
+ // the configured providers free-tier-first (or paid-first when freeFirst=false).
179
+ export function effectiveProvider(cfg) {
180
+ if (cfg.provider && cfg.provider !== "auto") {
181
+ return PROVIDERS[cfg.provider] && providerHasKey(cfg, cfg.provider) ? cfg.provider : null;
182
+ }
183
+ const order = cfg.freeFirst === false ? ORDER_PAID_FIRST : ORDER_FREE_FIRST;
184
+ return order.find((p) => providerHasKey(cfg, p)) || null;
185
+ }
186
+
187
+ // "gemini:gemini-2.5-flash" → {provider:"gemini", model:"gemini-2.5-flash"}.
188
+ // Models may contain ":" themselves (OpenRouter), so only the FIRST segment
189
+ // counts as a provider prefix.
190
+ export function parseModelRef(model) {
191
+ if (typeof model === "string" && model.includes(":")) {
192
+ const i = model.indexOf(":");
193
+ const p = model.slice(0, i);
194
+ const rest = model.slice(i + 1);
195
+ if (PROVIDERS[p] && rest) return { provider: p, model: rest };
196
+ }
197
+ return null;
198
+ }
199
+
200
+ export function modelFor(cfg) {
201
+ // an explicit model ref like "provider:model" pins both
202
+ const ref = parseModelRef(cfg.model);
203
+ if (ref && providerHasKey(cfg, ref.provider)) {
204
+ return ref.model;
205
+ }
206
+ const prov = ref ? null : effectiveProvider(cfg);
207
+ const meta = prov && PROVIDERS[prov];
208
+ if (!meta) return cfg.model || "claude-sonnet-4-5";
209
+ // auto mode + freeFirst → the provider's free model; explicit provider →
210
+ // its default model
211
+ const wantFree = cfg.provider === "auto" && cfg.freeFirst !== false;
212
+ return wantFree && meta.freeModel ? meta.freeModel : meta.defaultModel;
213
+ }
214
+
215
+ export function apiKeyFor(cfg) {
216
+ const prov = effectiveProvider(cfg);
217
+ const meta = prov && PROVIDERS[prov];
218
+ return meta ? cfg[meta.keyField] || "" : "";
219
+ }
220
+
221
+ export function estimateCost(cfg, usage) {
222
+ if (!usage) return null;
223
+ const p = cfg.pricing && cfg.pricing[modelFor(cfg)];
224
+ if (!p) return { tokens: usage, cost: null };
225
+ const cost = ((usage.input_tokens || 0) * p.input + (usage.output_tokens || 0) * p.output) / 1_000_000;
226
+ return { tokens: usage, cost };
227
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,159 @@
1
+ // Minimal MCP (Model Context Protocol) client over the Streamable HTTP
2
+ // transport — used to talk to the MCP server built into Roblox Studio
3
+ // (File → Studio Settings → Beta Features → "MCP Server", default
4
+ // http://localhost:3004/mcp).
5
+ //
6
+ // Implements just enough of the protocol: initialize → tools/list → tools/call.
7
+ // Responses may arrive as a single JSON object or as an SSE stream; both handled.
8
+ import { parseSSEStream } from "./util.js";
9
+
10
+ export class McpError extends Error {}
11
+
12
+ export class McpClient {
13
+ constructor(url, { timeoutMs = 30000 } = {}) {
14
+ this.url = url;
15
+ this.timeoutMs = timeoutMs;
16
+ this.sessionId = null;
17
+ this.nextId = 1;
18
+ this.serverInfo = null;
19
+ }
20
+
21
+ async request(method, params) {
22
+ const id = this.nextId++;
23
+ const headers = {
24
+ "content-type": "application/json",
25
+ accept: "application/json, text/event-stream",
26
+ };
27
+ if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
28
+
29
+ let res;
30
+ try {
31
+ res = await fetch(this.url, {
32
+ method: "POST",
33
+ headers,
34
+ body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
35
+ signal: AbortSignal.timeout(this.timeoutMs),
36
+ });
37
+ } catch (e) {
38
+ const reason = e.name === "TimeoutError" ? "timed out " : "";
39
+ throw new McpError(`Studio MCP unreachable at ${this.url} (${reason}${e.cause?.code || e.message})`);
40
+ }
41
+
42
+ const sid = res.headers.get("mcp-session-id");
43
+ if (sid) this.sessionId = sid;
44
+ const ctype = res.headers.get("content-type") || "";
45
+ const bodyText = await res.text();
46
+
47
+ if (!res.ok) {
48
+ let msg = bodyText.slice(0, 200);
49
+ try {
50
+ const j = JSON.parse(bodyText);
51
+ msg = (j.error && j.error.message) || msg;
52
+ } catch {
53
+ /* keep slice */
54
+ }
55
+ throw new McpError(`MCP HTTP ${res.status}: ${msg}`);
56
+ }
57
+
58
+ let payload = null;
59
+ if (ctype.includes("text/event-stream")) {
60
+ for (const ev of parseSSEStream(bodyText)) {
61
+ if (ev.event === "message" && ev.data && ev.data.id === id) {
62
+ payload = ev.data;
63
+ break;
64
+ }
65
+ }
66
+ if (!payload) {
67
+ // some servers send the result in an unnamed message event
68
+ const first = parseSSEStream(bodyText).find((e) => e.data && typeof e.data === "object" && e.data.result);
69
+ if (first) payload = first.data;
70
+ }
71
+ } else {
72
+ try {
73
+ payload = JSON.parse(bodyText);
74
+ } catch {
75
+ if (bodyText.trim()) throw new McpError(`MCP: unparseable response: ${bodyText.slice(0, 120)}`);
76
+ }
77
+ }
78
+ if (!payload) throw new McpError(`MCP: no response for request ${id}`);
79
+ if (payload.error) throw new McpError(`MCP error ${payload.error.code}: ${payload.error.message}`);
80
+ return payload.result;
81
+ }
82
+
83
+ async notify(method, params) {
84
+ const headers = {
85
+ "content-type": "application/json",
86
+ accept: "application/json, text/event-stream",
87
+ };
88
+ if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
89
+ try {
90
+ const res = await fetch(this.url, {
91
+ method: "POST",
92
+ headers,
93
+ body: JSON.stringify({ jsonrpc: "2.0", method, params }),
94
+ signal: AbortSignal.timeout(10000),
95
+ });
96
+ await res.arrayBuffer(); // drain
97
+ } catch {
98
+ /* notifications are best-effort */
99
+ }
100
+ }
101
+
102
+ async connect() {
103
+ const result = await this.request("initialize", {
104
+ protocolVersion: "2025-03-26",
105
+ capabilities: {},
106
+ clientInfo: { name: "roforge-cli", version: "0.2.0" },
107
+ });
108
+ this.serverInfo = result.serverInfo || null;
109
+ await this.notify("notifications/initialized", {});
110
+ return result;
111
+ }
112
+
113
+ async listTools() {
114
+ const result = await this.request("tools/list", {});
115
+ return (result && result.tools) || [];
116
+ }
117
+
118
+ // Returns { text, isError, image? } — image is {base64, mediaType} when the
119
+ // tool returned an MCP image content block (e.g. Studio's built-in
120
+ // screenshot/capture tools) or a resource blob.
121
+ async callTool(name, args) {
122
+ const result = await this.request("tools/call", { name, arguments: args || {} });
123
+ const content = (result && result.content) || [];
124
+ const texts = [];
125
+ let image = null;
126
+ for (const c of content) {
127
+ if (c.type === "text" && typeof c.text === "string") {
128
+ texts.push(c.text);
129
+ } else if (c.type === "image" && typeof c.data === "string" && c.data.length) {
130
+ image = image || { base64: c.data, mediaType: c.mimeType || "image/png" };
131
+ } else if (c.type === "resource" && c.resource) {
132
+ if (typeof c.resource.blob === "string" && c.resource.blob.length) {
133
+ image = image || { base64: c.resource.blob, mediaType: c.resource.mimeType || "image/png" };
134
+ }
135
+ if (typeof c.resource.text === "string") texts.push(c.resource.text);
136
+ } else {
137
+ texts.push(JSON.stringify(c));
138
+ }
139
+ }
140
+ return { text: texts.join("\n") || "(no output)", isError: Boolean(result && result.isError), image: image || undefined };
141
+ }
142
+ }
143
+
144
+ // Probe: is a Studio MCP server answering at url? Returns {ok, serverInfo, toolCount} or {ok:false, error}.
145
+ export async function probeMcp(url, { timeoutMs = 4000 } = {}) {
146
+ const client = new McpClient(url, { timeoutMs });
147
+ try {
148
+ const init = await client.connect();
149
+ let toolCount = -1;
150
+ try {
151
+ toolCount = (await client.listTools()).length;
152
+ } catch {
153
+ /* connected but tools/list failed — still ok */
154
+ }
155
+ return { ok: true, serverInfo: init.serverInfo, toolCount };
156
+ } catch (e) {
157
+ return { ok: false, error: e.message };
158
+ }
159
+ }