@holin-work/holin-cli 1.1.3 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/holin-cli.js +133 -31
  2. package/package.json +1 -1
package/bin/holin-cli.js CHANGED
@@ -119,28 +119,81 @@ async function pingVerify(apiKey) {
119
119
  throw new Error("Unexpected response format from MCP server");
120
120
  }
121
121
 
122
+ /**
123
+ * Call set_holin_credentials on MCP server to establish server-side session.
124
+ * This is required for cli-login mode: Accio sends requests without API key,
125
+ * server reads key from session established by this call.
126
+ */
127
+ async function setCredentials(apiKey) {
128
+ const res = await fetch(UPSTREAM_URL, {
129
+ method: "POST",
130
+ headers: {
131
+ "Content-Type": "application/json",
132
+ Accept: "application/json, text/event-stream",
133
+ "X-API-Key": apiKey,
134
+ },
135
+ body: JSON.stringify({
136
+ jsonrpc: "2.0",
137
+ id: 2,
138
+ method: "tools/call",
139
+ params: { name: "set_holin_credentials", arguments: { api_key: apiKey } },
140
+ }),
141
+ signal: AbortSignal.timeout(10000),
142
+ });
143
+
144
+ if (!res.ok) throw new Error(`MCP server responded ${res.status}`);
145
+
146
+ const raw = await res.text();
147
+ for (const line of raw.split("\n")) {
148
+ const trimmed = line.trim();
149
+ if (trimmed.startsWith("data:")) {
150
+ const envelope = JSON.parse(trimmed.slice(5).trim());
151
+ if (envelope?.error) {
152
+ throw new Error(envelope.error.message || "set_holin_credentials failed");
153
+ }
154
+ const text = envelope?.result?.content?.[0]?.text;
155
+ if (text) return JSON.parse(text);
156
+ return { ok: true };
157
+ }
158
+ }
159
+ throw new Error("Unexpected response format from set_holin_credentials");
160
+ }
161
+
122
162
  // ── Proxy Server ─────────────────────────────────────────────────────────────
123
163
 
124
164
  function createProxyServer() {
125
165
  const server = http.createServer((req, res) => {
126
- // Block OAuth discovery probes — return 401 so platform knows auth is required
127
- if (req.url.startsWith("/.well-known/")) {
128
- const body = JSON.stringify({ error: "unauthorized" });
129
- res.writeHead(401, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
130
- res.end(body);
131
- return;
132
- }
166
+ // Collect request body for logging
167
+ const chunks = [];
168
+ req.on("data", (c) => chunks.push(c));
169
+ req.on("end", () => {
170
+ const rawBody = Buffer.concat(chunks).toString("utf8");
171
+ let bodyPreview = rawBody.slice(0, 300);
172
+ try {
173
+ const parsed = JSON.parse(rawBody);
174
+ bodyPreview = JSON.stringify({ method: parsed.method, id: parsed.id, params: parsed.params ? Object.keys(parsed.params) : undefined });
175
+ } catch { /* not JSON */ }
176
+
177
+ log(`[proxy] ${req.method} ${req.url} body=${bodyPreview}`);
178
+
179
+ // Block OAuth discovery probes — return 401 so platform knows auth is required
180
+ if (req.url.startsWith("/.well-known/")) {
181
+ const body = JSON.stringify({ error: "unauthorized" });
182
+ res.writeHead(401, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
183
+ res.end(body);
184
+ return;
185
+ }
133
186
 
134
- // Read credentials on every request (hot-reload support)
135
- const creds = readCredentials();
136
- if (!creds?.api_key) {
137
- // No credentials: return 401 so platform knows auth is required (not "no auth needed")
138
- const body = JSON.stringify({ error: "unauthorized", message: "Run 'holin-cli auth login' first." });
139
- res.writeHead(401, {
140
- "Content-Type": "application/json",
141
- "Content-Length": Buffer.byteLength(body),
142
- "WWW-Authenticate": "Bearer realm=\"Holin API\"",
143
- });
187
+ // Read credentials on every request (hot-reload support)
188
+ const creds = readCredentials();
189
+ if (!creds?.api_key) {
190
+ // No credentials: return 401 so platform knows auth is required (not "no auth needed")
191
+ const body = JSON.stringify({ error: "unauthorized", message: "Run 'holin-cli auth login' first." });
192
+ res.writeHead(401, {
193
+ "Content-Type": "application/json",
194
+ "Content-Length": Buffer.byteLength(body),
195
+ "WWW-Authenticate": "Bearer realm=\"Holin API\"",
196
+ });
144
197
  res.end(body);
145
198
  return;
146
199
  }
@@ -167,26 +220,64 @@ function createProxyServer() {
167
220
  // MCP streamable-http has a single endpoint; forward directly to upstream path.
168
221
  // Accio requests http://127.0.0.1:18787/mcp → upstream https://api.holin.work/icbu/mcp
169
222
  const upstreamUrl = new URL(UPSTREAM_URL);
223
+ const bodyBuffer = Buffer.concat(chunks);
224
+ const forwardHeaders = { ...req.headers };
225
+ delete forwardHeaders["transfer-encoding"];
226
+ forwardHeaders["content-length"] = bodyBuffer.length;
227
+ forwardHeaders["host"] = upstreamUrl.hostname;
228
+ forwardHeaders["X-API-Key"] = creds.api_key;
229
+
170
230
  const options = {
171
231
  hostname: upstreamUrl.hostname,
172
232
  port: upstreamUrl.port || 443,
173
233
  path: upstreamUrl.pathname,
174
234
  method: req.method,
175
- headers: {
176
- ...req.headers,
177
- host: upstreamUrl.hostname,
178
- "X-API-Key": creds.api_key,
179
- },
235
+ headers: forwardHeaders,
180
236
  };
181
237
 
182
- const proxyReq = https.request(options, (proxyRes) => {
183
- // Forward response headers (strip content-length for chunked SSE)
184
- const headers = { ...proxyRes.headers };
185
- delete headers["content-length"];
186
- delete headers["transfer-encoding"];
238
+ // Parse request body to determine MCP method
239
+ let mcpMethod = "";
240
+ try {
241
+ const parsed = JSON.parse(rawBody);
242
+ mcpMethod = parsed.method || "";
243
+ } catch { /* not JSON */ }
244
+
245
+ // Non-streaming methods should return JSON, not SSE
246
+ // Accio expects JSON for initialize and tools/list
247
+ const shouldConvertToJson = mcpMethod === "initialize" || mcpMethod === "tools/list";
187
248
 
188
- res.writeHead(proxyRes.statusCode || 502, headers);
189
- proxyRes.pipe(res);
249
+ const proxyReq = https.request(options, (proxyRes) => {
250
+ log(`[proxy] ← ${proxyRes.statusCode} content-type=${proxyRes.headers["content-type"]} method=${mcpMethod}`);
251
+
252
+ if (shouldConvertToJson && proxyRes.headers["content-type"]?.includes("text/event-stream")) {
253
+ // Collect SSE response and convert to JSON
254
+ const sseChunks = [];
255
+ proxyRes.on("data", (c) => sseChunks.push(c));
256
+ proxyRes.on("end", () => {
257
+ const sseText = Buffer.concat(sseChunks).toString("utf8");
258
+ let jsonBody = sseText;
259
+ // Parse SSE: extract the last "data:" line
260
+ for (const line of sseText.split("\n")) {
261
+ const trimmed = line.trim();
262
+ if (trimmed.startsWith("data:")) {
263
+ jsonBody = trimmed.slice(5).trim();
264
+ }
265
+ }
266
+ const headers = { ...proxyRes.headers };
267
+ headers["content-type"] = "application/json";
268
+ headers["content-length"] = Buffer.byteLength(jsonBody);
269
+ delete headers["transfer-encoding"];
270
+ res.writeHead(proxyRes.statusCode || 502, headers);
271
+ res.end(jsonBody);
272
+ });
273
+ } else {
274
+ // Stream SSE directly for tools/call and other methods
275
+ const headers = { ...proxyRes.headers };
276
+ delete headers["content-length"];
277
+ delete headers["transfer-encoding"];
278
+ res.writeHead(proxyRes.statusCode || 502, headers);
279
+ proxyRes.pipe(res);
280
+ }
190
281
  });
191
282
 
192
283
  proxyReq.on("error", (err) => {
@@ -211,8 +302,10 @@ function createProxyServer() {
211
302
  }
212
303
  });
213
304
 
214
- req.pipe(proxyReq);
215
- });
305
+ proxyReq.write(bodyBuffer);
306
+ proxyReq.end();
307
+ }); // end req.on("end")
308
+ }); // end http.createServer
216
309
 
217
310
  return server;
218
311
  }
@@ -255,6 +348,15 @@ async function cmdAuthLogin() {
255
348
  writeCredentials(creds);
256
349
  console.log(`[holin-cli] Connected: ${creds.customer_name || creds.customer_id} (${creds.plan})`);
257
350
 
351
+ // Establish server-side session via set_holin_credentials
352
+ // Accio sends MCP requests without API key; server reads key from this session.
353
+ try {
354
+ await setCredentials(apiKey);
355
+ console.log("[holin-cli] Server-side session established.");
356
+ } catch (err) {
357
+ console.error(`[holin-cli] Warning: Failed to establish server session — ${err.message}`);
358
+ }
359
+
258
360
  // Auto-start proxy in background (detached), wait until it's listening
259
361
  try {
260
362
  await startProxyDaemon();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holin-work/holin-cli",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "description": "Holin CLI — Accio plugin authorization tool for ICBU batch listing",
5
5
  "type": "module",
6
6
  "bin": {