jaitus 0.3.23 → 0.3.25

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/src/mcp.js CHANGED
@@ -1,7 +1,8 @@
1
- // ─── Model Context Protocol over stdio ─────────────────────────────────────────
1
+ // ─── Model Context Protocol: local (stdio) and remote (HTTP) servers ────────────
2
2
  //
3
- // Jaitus supports the portable part of MCP that is most useful in a terminal: local
4
- // stdio servers exposing tools.
3
+ // Jaitus supports the portable part of MCP that is most useful in a terminal: servers
4
+ // exposing tools — local ones over stdio, and remote ones over Streamable HTTP or the
5
+ // older HTTP+SSE transport.
5
6
  //
6
7
  // Servers come from two places, merged: your own (~/.jaitus/mcp.json, added with
7
8
  // `jaitus mcp add --global`) and the project's (.mcp.json). A project's file comes with
@@ -16,6 +17,8 @@
16
17
 
17
18
  import crypto from 'node:crypto';
18
19
  import fs from 'node:fs';
20
+ import http from 'node:http';
21
+ import https from 'node:https';
19
22
  import path from 'node:path';
20
23
  import { spawn, spawnSync } from 'node:child_process';
21
24
  import { SESSIONS, loadSettings, saveSettings } from './config.js';
@@ -45,10 +48,40 @@ export function readMcpConfig(cwd) {
45
48
  return { file: projectFile(cwd), servers };
46
49
  }
47
50
 
51
+ // ── Remote servers ──
52
+ // The two HTTP transports other agents read from the same .mcp.json shape:
53
+ // { "type": "http", "url": "https://…/mcp", "headers": { "Authorization": "Bearer ${TOKEN}" } }
54
+ // { "type": "sse", "url": "https://…/sse", … } (the older HTTP+SSE transport)
55
+ // ${VAR} and ${VAR:-fallback} in the url and headers are read from the environment, so
56
+ // a token never has to be written into a file that is committed.
57
+ const REMOTE_TYPES = new Set(['http', 'streamable-http', 'sse']);
58
+ export const isRemote = (config) => Boolean(config?.url) && REMOTE_TYPES.has(String(config?.type || 'http').toLowerCase()) && !config?.command;
59
+ export function expandEnv(value, env = process.env) {
60
+ return String(value ?? '').replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, name, fallback) => (env[name] !== undefined && env[name] !== '' ? env[name] : (fallback ?? '')));
61
+ }
62
+ function remoteTarget(config) {
63
+ const url = expandEnv(config.url);
64
+ let u;
65
+ try { u = new URL(url); } catch { throw new Error(`MCP server url is not a valid address: ${url}`); }
66
+ // A token in a header must not cross the internet in plain text: http only to this
67
+ // computer, https everywhere else.
68
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
69
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) throw new Error(`MCP server url must be https:// (http:// only for this computer): ${url}`);
70
+ const headers = {};
71
+ for (const [k, v] of Object.entries(config.headers || {})) headers[String(k)] = expandEnv(v);
72
+ return { url: u, headers };
73
+ }
74
+
48
75
  // ── Trust ──
49
- /** What is approved: the command line itself. Change the command and it asks again. */
76
+ /**
77
+ * What is approved: the command line itself, or for a remote server its address and
78
+ * headers. Change it and it asks again. (A stdio server's fingerprint is unchanged from
79
+ * before remote servers existed, so nothing already approved has to be approved again.)
80
+ */
50
81
  export function fingerprint(config) {
51
- const body = JSON.stringify({ command: config?.command || '', args: config?.args || [], env: config?.env || {} });
82
+ const body = isRemote(config)
83
+ ? JSON.stringify({ type: String(config.type || 'http').toLowerCase(), url: config.url, headers: config.headers || {} })
84
+ : JSON.stringify({ command: config?.command || '', args: config?.args || [], env: config?.env || {} });
52
85
  return crypto.createHash('sha256').update(body).digest('hex').slice(0, 24);
53
86
  }
54
87
  const trustKey = (cwd) => path.resolve(cwd).toLowerCase();
@@ -65,7 +98,7 @@ export function trustServer(cwd, server) {
65
98
  discovery.clear();
66
99
  }
67
100
  export function untrustedServers(cwd) {
68
- return allServers(cwd).filter((s) => s.config?.command && !isTrusted(cwd, s));
101
+ return allServers(cwd).filter((s) => (s.config?.command || isRemote(s.config)) && !isTrusted(cwd, s));
69
102
  }
70
103
 
71
104
  export function listMcpServers(cwd) {
@@ -73,7 +106,7 @@ export function listMcpServers(cwd) {
73
106
  name: s.name,
74
107
  scope: s.scope,
75
108
  trusted: isTrusted(cwd, s),
76
- transport: s.config?.type || (s.config?.command ? 'stdio' : 'unknown'),
109
+ transport: s.config?.command ? 'stdio' : isRemote(s.config) ? String(s.config.type || 'http').toLowerCase() : 'unknown',
77
110
  command: [s.config?.command || s.config?.url || '', ...(s.config?.args || [])].join(' ').trim(),
78
111
  }));
79
112
  }
@@ -218,6 +251,214 @@ class StdioClient {
218
251
  }
219
252
  }
220
253
 
254
+ /**
255
+ * Server-sent events from a stream of text chunks: yields { event, data } per event.
256
+ * Lines are `field: value`; an event ends at a blank line; `data` lines are joined.
257
+ */
258
+ function sseParser(onEvent) {
259
+ let buffer = '';
260
+ let event = 'message';
261
+ let data = [];
262
+ return (chunk) => {
263
+ buffer += chunk;
264
+ let at;
265
+ while ((at = buffer.search(/\r?\n/)) >= 0) {
266
+ const line = buffer.slice(0, at);
267
+ buffer = buffer.slice(at + (buffer[at] === '\r' ? 2 : 1));
268
+ if (line === '') {
269
+ if (data.length) onEvent({ event, data: data.join('\n') });
270
+ event = 'message'; data = [];
271
+ } else if (!line.startsWith(':')) {
272
+ const colon = line.indexOf(':');
273
+ const field = colon < 0 ? line : line.slice(0, colon);
274
+ const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
275
+ if (field === 'event') event = value;
276
+ else if (field === 'data') data.push(value);
277
+ }
278
+ }
279
+ };
280
+ }
281
+
282
+ const remoteError = (status, text) => {
283
+ if (status === 401 || status === 403) return new Error(`MCP server refused the request (${status}) — check the token in its "headers" (an unset \${VAR} becomes empty).`);
284
+ return new Error(`MCP server answered ${status}${text ? `: ${String(text).replace(/\s+/g, ' ').slice(0, 200)}` : ''}`);
285
+ };
286
+
287
+ /**
288
+ * Streamable HTTP (MCP 2025-03-26 and later): every message is a POST; the answer is JSON,
289
+ * or an event stream carrying it. The server may hand out a session id on initialize,
290
+ * which then goes with every request.
291
+ */
292
+ class HttpClient {
293
+ constructor(config) {
294
+ this.config = config;
295
+ this.nextId = 1;
296
+ this.sessionId = null;
297
+ this.protocol = '2025-06-18';
298
+ this.initialized = false;
299
+ this.alive = false;
300
+ }
301
+
302
+ async start() {
303
+ this.target = remoteTarget(this.config);
304
+ const result = await this.request('initialize', { protocolVersion: this.protocol, capabilities: {}, clientInfo: { name: 'jaitus', version: '0.3' } }, 20_000);
305
+ if (result?.protocolVersion) this.protocol = result.protocolVersion;
306
+ this.initialized = true;
307
+ this.alive = true;
308
+ await this.notify('notifications/initialized', {});
309
+ return this;
310
+ }
311
+
312
+ async post(message, timeoutMs) {
313
+ const ctl = new AbortController();
314
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
315
+ const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...this.target.headers };
316
+ if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
317
+ if (this.initialized) headers['mcp-protocol-version'] = this.protocol;
318
+ try {
319
+ const res = await fetch(this.target.url, { method: 'POST', headers, body: JSON.stringify(message), signal: ctl.signal });
320
+ const sid = res.headers.get('mcp-session-id');
321
+ if (sid) this.sessionId = sid;
322
+ if (message.id === undefined) { try { await res.body?.cancel(); } catch { /* done */ } return null; }
323
+ if (res.status === 404 && this.sessionId) { this.alive = false; throw new Error('MCP session ended on the server; it is started again on the next call'); }
324
+ if (!res.ok) throw remoteError(res.status, await res.text().catch(() => ''));
325
+ const type = String(res.headers.get('content-type') || '');
326
+ if (type.includes('text/event-stream')) {
327
+ // The answer is one event among possibly several (progress, server requests).
328
+ const reader = res.body.getReader();
329
+ const decoder = new TextDecoder();
330
+ let found = null;
331
+ const feed = sseParser(({ data }) => {
332
+ try { const m = JSON.parse(data); for (const one of Array.isArray(m) ? m : [m]) if (one?.id === message.id && ('result' in one || 'error' in one)) found = one; } catch { /* not JSON */ }
333
+ });
334
+ while (!found) {
335
+ const { done, value } = await reader.read();
336
+ if (done) break;
337
+ feed(decoder.decode(value, { stream: true }));
338
+ }
339
+ try { await reader.cancel(); } catch { /* done */ }
340
+ if (!found) throw new Error(`MCP server closed the stream without answering ${message.method}`);
341
+ return found;
342
+ }
343
+ const body = await res.json();
344
+ const list = Array.isArray(body) ? body : [body];
345
+ const found = list.find((m) => m?.id === message.id);
346
+ if (!found) throw new Error(`MCP server did not answer ${message.method}`);
347
+ return found;
348
+ } catch (e) {
349
+ if (e?.name === 'AbortError') throw new Error(`MCP request timed out after ${Math.round(timeoutMs / 1000)}s: ${message.method}`);
350
+ if (e?.cause?.code === 'ECONNREFUSED' || /fetch failed/.test(e?.message || '')) throw new Error(`Could not reach MCP server at ${this.target.url.origin}`);
351
+ throw e;
352
+ } finally {
353
+ clearTimeout(timer);
354
+ }
355
+ }
356
+
357
+ async request(method, params, timeoutMs = 120_000) {
358
+ const id = this.nextId++;
359
+ const m = await this.post({ jsonrpc: '2.0', id, method, params }, timeoutMs);
360
+ if (m.error) throw new Error(m.error.message || 'MCP request failed');
361
+ return m.result;
362
+ }
363
+
364
+ notify(method, params) {
365
+ return this.post({ jsonrpc: '2.0', method, params }, 10_000).catch(() => null);
366
+ }
367
+
368
+ close() {
369
+ this.alive = false;
370
+ // Tell the server the session is over (it may keep state for it). Best effort.
371
+ if (this.sessionId && this.target) {
372
+ fetch(this.target.url, { method: 'DELETE', headers: { ...this.target.headers, 'mcp-session-id': this.sessionId }, signal: AbortSignal.timeout(2000) }).catch(() => {});
373
+ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * The older HTTP+SSE transport (MCP 2024-11-05): a GET opens an event stream whose first
379
+ * event names where to POST; answers arrive on the stream. The stream's socket does not
380
+ * hold the process open (a finished one-shot run still exits).
381
+ */
382
+ class SseClient {
383
+ constructor(config) {
384
+ this.config = config;
385
+ this.nextId = 1;
386
+ this.pending = new Map();
387
+ this.alive = false;
388
+ }
389
+
390
+ start() {
391
+ this.target = remoteTarget(this.config);
392
+ return new Promise((resolve, reject) => {
393
+ const u = this.target.url;
394
+ const lib = u.protocol === 'https:' ? https : http;
395
+ const giveUp = setTimeout(() => { reject(new Error('MCP server did not send its message address within 20s')); this.close(); }, 20_000);
396
+ this.req = lib.request(u, { method: 'GET', headers: { accept: 'text/event-stream', ...this.target.headers } }, (res) => {
397
+ if (res.statusCode !== 200) {
398
+ clearTimeout(giveUp);
399
+ let text = '';
400
+ res.setEncoding('utf8');
401
+ res.on('data', (d) => { text += d; });
402
+ res.on('end', () => reject(remoteError(res.statusCode, text)));
403
+ return;
404
+ }
405
+ res.socket?.unref?.();
406
+ res.setEncoding('utf8');
407
+ const feed = sseParser(({ event, data }) => {
408
+ if (event === 'endpoint') {
409
+ clearTimeout(giveUp);
410
+ try { this.postUrl = new URL(data.trim(), u); } catch { reject(new Error(`MCP server sent a bad message address: ${data}`)); return; }
411
+ this.alive = true;
412
+ this.initialize().then(() => resolve(this), (e) => { this.close(); reject(e); });
413
+ return;
414
+ }
415
+ try {
416
+ const m = JSON.parse(data);
417
+ if (m?.id !== undefined && this.pending.has(m.id)) {
418
+ const p = this.pending.get(m.id);
419
+ this.pending.delete(m.id);
420
+ if (m.error) p.reject(new Error(m.error.message || 'MCP request failed')); else p.resolve(m.result);
421
+ }
422
+ } catch { /* not JSON */ }
423
+ });
424
+ res.on('data', feed);
425
+ res.on('close', () => { this.alive = false; this.rejectAll(new Error('MCP server closed its event stream')); });
426
+ });
427
+ this.req.on('error', (e) => { clearTimeout(giveUp); this.alive = false; reject(new Error(`Could not reach MCP server at ${u.origin}: ${e.message}`)); this.rejectAll(e); });
428
+ this.req.end();
429
+ });
430
+ }
431
+
432
+ async initialize() {
433
+ await this.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'jaitus', version: '0.3' } }, 20_000);
434
+ await this.notify('notifications/initialized', {});
435
+ }
436
+
437
+ async send(message) {
438
+ const res = await fetch(this.postUrl, { method: 'POST', headers: { 'content-type': 'application/json', ...this.target.headers }, body: JSON.stringify(message), signal: AbortSignal.timeout(20_000) });
439
+ if (!res.ok) throw remoteError(res.status, await res.text().catch(() => ''));
440
+ try { await res.body?.cancel(); } catch { /* done */ }
441
+ }
442
+
443
+ request(method, params, timeoutMs = 120_000) {
444
+ const id = this.nextId++;
445
+ return new Promise((resolve, reject) => {
446
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`MCP request timed out after ${Math.round(timeoutMs / 1000)}s: ${method}`)); }, timeoutMs);
447
+ this.pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); reject(e); } });
448
+ this.send({ jsonrpc: '2.0', id, method, params }).catch((e) => { const p = this.pending.get(id); if (p) { this.pending.delete(id); p.reject(e); } });
449
+ });
450
+ }
451
+
452
+ notify(method, params) { return this.send({ jsonrpc: '2.0', method, params }).catch(() => null); }
453
+
454
+ rejectAll(error) { for (const p of this.pending.values()) p.reject(error); this.pending.clear(); }
455
+
456
+ close() {
457
+ this.alive = false;
458
+ try { this.req?.destroy(); } catch { /* gone */ }
459
+ }
460
+ }
461
+
221
462
  // One live client per server for the session.
222
463
  const clients = new Map();
223
464
  function closeAll() { for (const client of clients.values()) client.close(); clients.clear(); }
@@ -226,7 +467,8 @@ process.once('exit', closeAll);
226
467
  async function clientFor(key, config, cwd) {
227
468
  const have = clients.get(key);
228
469
  if (have?.alive) return have;
229
- const fresh = new StdioClient(config, cwd);
470
+ const fresh = !isRemote(config) ? new StdioClient(config, cwd)
471
+ : String(config.type || 'http').toLowerCase() === 'sse' ? new SseClient(config) : new HttpClient(config);
230
472
  await fresh.start();
231
473
  clients.set(key, fresh);
232
474
  return fresh;
@@ -258,7 +500,7 @@ export function discoverMcpTools(cwd) {
258
500
  const key = `${path.resolve(cwd)}|${stamp(cwd)}|${JSON.stringify(loadSettings().trustedMcp?.[trustKey(cwd)] || {})}`;
259
501
  if (!discovery.has(key)) {
260
502
  discovery.set(key, (async () => {
261
- const servers = allServers(cwd).filter((s) => s.config?.command && isTrusted(cwd, s));
503
+ const servers = allServers(cwd).filter((s) => (s.config?.command || isRemote(s.config)) && isTrusted(cwd, s));
262
504
  const lists = await Promise.all(servers.map(async (server) => {
263
505
  const ckey = `${path.resolve(cwd)}|${server.scope}|${server.name}|${fingerprint(server.config)}`;
264
506
  try {
package/src/project.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // starts from the code, which is what it did before these existed.
12
12
 
13
13
  import fs from 'node:fs';
14
+ import os from 'node:os';
14
15
  import path from 'node:path';
15
16
 
16
17
  export const CONTEXT_FILE = 'JAITUS.md';
@@ -46,6 +47,38 @@ npm test
46
47
  `;
47
48
 
48
49
  /** The project briefing, or null. Truncated rather than refused if someone writes an essay. */
50
+ /**
51
+ * Personal notes: ~/.jaitus/JAITUS.md, sent with EVERY project — how you like to work,
52
+ * wherever you work ("answer briefly", "I use pnpm", "never push without asking"). The
53
+ * project's own JAITUS.md is the other half; the two are labelled apart in the prompt.
54
+ */
55
+ export function userContextFile() {
56
+ return path.join(os.homedir(), '.jaitus', CONTEXT_FILE); // the same folder as config.HOME
57
+ }
58
+
59
+ export function loadUserContext() {
60
+ return readNotes(userContextFile(), '~/.jaitus/JAITUS.md');
61
+ }
62
+
63
+ /** Append one line to a notes file, creating it (and its folder) with a header. */
64
+ export function appendNote(file, note, header) {
65
+ fs.mkdirSync(path.dirname(file), { recursive: true });
66
+ if (!fs.existsSync(file)) fs.writeFileSync(file, header, 'utf8');
67
+ const text = fs.readFileSync(file, 'utf8');
68
+ fs.appendFileSync(file, `${text.endsWith('\n') || !text ? '' : '\n'}- ${String(note).replace(/[\r\n]+/g, ' ').trim()}\n`, 'utf8');
69
+ }
70
+
71
+ export const USER_CONTEXT_HEADER = `# My notes for Jaitus\n\nSent with every project. Keep it short and true — how you like to work, wherever you work.\n\n`;
72
+ export const PROJECT_CONTEXT_HEADER = `# ${CONTEXT_FILE}\n\nNotes for the Jaitus agent working in this directory.\n\n`;
73
+
74
+ function readNotes(file, name) {
75
+ let text;
76
+ try { text = fs.readFileSync(file, 'utf8').trim(); } catch { return null; }
77
+ if (!text) return null;
78
+ if (text.length > MAX_CONTEXT) text = `${text.slice(0, MAX_CONTEXT)}\n\n[${name} truncated here — it is longer than the agent will read]`;
79
+ return text;
80
+ }
81
+
49
82
  export function loadContext(cwd) {
50
83
  const file = path.join(cwd, CONTEXT_FILE);
51
84
  let text;