jaitus 0.3.24 → 0.3.26
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/package.json +2 -2
- package/src/adSlots.js +153 -0
- package/src/agent/askUser.js +161 -0
- package/src/agent/loop.js +49 -13
- package/src/agent/permissions.js +2 -0
- package/src/agent/tools.js +52 -0
- package/src/index.js +100 -25
- package/src/mcp.js +251 -9
- package/src/project.js +33 -0
- package/src/tui-ink.js +201 -16
- package/src/tui.js +16 -0
package/src/index.js
CHANGED
|
@@ -23,17 +23,18 @@ import { DEFAULT_DENY } from './agent/rules.js';
|
|
|
23
23
|
import { forgetIgnoreCache, imageBlock, IMAGE_MIME } from './agent/tools.js';
|
|
24
24
|
import { shrinkImageFile, clipboardBytes } from './tui/clipboard.js';
|
|
25
25
|
import { THEME_NAMES } from './tui/theme.js';
|
|
26
|
-
import { loadContext, CONTEXT_FILE } from './project.js';
|
|
26
|
+
import { loadContext, loadUserContext, userContextFile, appendNote, USER_CONTEXT_HEADER, PROJECT_CONTEXT_HEADER, CONTEXT_FILE } from './project.js';
|
|
27
27
|
import { discoverSkills, skillCatalog } from './skills.js';
|
|
28
28
|
import { runHook, configuredHookEvents } from './hooks.js';
|
|
29
29
|
import { newSession, saveSession, loadSession, latestSession, listSessions, sessionSummary, replaySession, sameDir } from './sessions.js';
|
|
30
30
|
import { createJournal, displayPath } from './rewind.js';
|
|
31
31
|
import { createLedger } from './ledger.js';
|
|
32
|
-
import { listMcpServers, addMcpServer, removeMcpServer, untrustedServers, trustServer, discoverMcpTools, mcpProblems, allServers } from './mcp.js';
|
|
32
|
+
import { listMcpServers, addMcpServer, removeMcpServer, untrustedServers, trustServer, discoverMcpTools, mcpProblems, allServers, isRemote, expandEnv } from './mcp.js';
|
|
33
33
|
import { banner, wordmark, renderHomeLogo, startupTip } from './brand.js';
|
|
34
|
+
import { adSourceFor } from './adSlots.js';
|
|
34
35
|
import {
|
|
35
36
|
c, out, err, ok, fail, info, warn, table, present,
|
|
36
|
-
promptLine, useHistoryFile, watchEsc, setTuiSink, askKey, colorLevel,
|
|
37
|
+
promptLine, useHistoryFile, watchEsc, setTuiSink, askKey, ask, colorLevel,
|
|
37
38
|
unicode, glyph, columns,
|
|
38
39
|
} from './ui.js';
|
|
39
40
|
import { createTui as createClassicTui } from './tui.js';
|
|
@@ -583,7 +584,7 @@ const SLASH = {
|
|
|
583
584
|
'/diff': { summary: 'uncommitted changes, staged or not, and new files' },
|
|
584
585
|
'/tasks': { summary: 'review tool activity from this session' },
|
|
585
586
|
'/image <path>': { summary: 'attach an image (png/jpg/gif/webp) the model will see with your next message' },
|
|
586
|
-
'/memory [text]': { summary: 'show or
|
|
587
|
+
'/memory [text]': { summary: 'show your notes for the agent, or add one (# does this inline)' },
|
|
587
588
|
'/permissions [mode|rules|default]': { summary: 'permission mode, the mode new sessions start in, and the allow/deny rules for this project' },
|
|
588
589
|
'/skills': { summary: 'list project workflows' },
|
|
589
590
|
'/sessions': { summary: 'list saved sessions in this directory' },
|
|
@@ -843,6 +844,8 @@ async function session({ flags }) {
|
|
|
843
844
|
],
|
|
844
845
|
fileProvider: (frag) => completeFiles(frag),
|
|
845
846
|
settings: loadSettings(),
|
|
847
|
+
// Sponsored lines (adSlots.js). The server decides who sees them; Max never does.
|
|
848
|
+
ads: adSourceFor(client),
|
|
846
849
|
meta: {
|
|
847
850
|
version: VERSION, cwd: flags.cwd, model, mode: permissions.mode, intent: 'build',
|
|
848
851
|
session: sessionState.id, branch: gitBranch(flags.cwd),
|
|
@@ -1244,8 +1247,7 @@ async function session({ flags }) {
|
|
|
1244
1247
|
if (input.startsWith('#')) {
|
|
1245
1248
|
const note = input.slice(1).trim();
|
|
1246
1249
|
if (note) {
|
|
1247
|
-
|
|
1248
|
-
ok(`Remembered in ${CONTEXT_FILE}: ${c.bold(note.slice(0, 60))}${note.length > 60 ? '…' : ''}`);
|
|
1250
|
+
await rememberNote(flags.cwd, note, tui);
|
|
1249
1251
|
} else {
|
|
1250
1252
|
const existing = loadContext(flags.cwd);
|
|
1251
1253
|
out(existing ? existing : c.dim(`(no ${CONTEXT_FILE} yet — type "# note text" to add one)`));
|
|
@@ -1407,6 +1409,11 @@ async function session({ flags }) {
|
|
|
1407
1409
|
const picked = list[Number(key) - 1];
|
|
1408
1410
|
return picked === undefined ? null : (picked && typeof picked === 'object' ? picked : { value: picked });
|
|
1409
1411
|
};
|
|
1412
|
+
tui.askText = async (question) => {
|
|
1413
|
+
if (!process.stdin.isTTY) return null;
|
|
1414
|
+
const typed = await ask(` ${c.bold(String(question).replace(/\x1b\[[0-9;]*m/g, ''))}\n ${c.dim('›')} `);
|
|
1415
|
+
return typed || null;
|
|
1416
|
+
};
|
|
1410
1417
|
tui.showPanel = async (title, lines) => {
|
|
1411
1418
|
out(` ${c.bold(String(title))}`);
|
|
1412
1419
|
for (const line of (Array.isArray(lines) ? lines : String(lines).split('\n'))) out(` ${line}`);
|
|
@@ -1436,11 +1443,16 @@ async function session({ flags }) {
|
|
|
1436
1443
|
// exact command is approved here — asked once, remembered per project and command.
|
|
1437
1444
|
void (async () => {
|
|
1438
1445
|
for (const server of untrustedServers(flags.cwd)) {
|
|
1439
|
-
const
|
|
1446
|
+
const remote = isRemote(server.config);
|
|
1447
|
+
const line = remote ? server.config.url : [server.config.command, ...(server.config.args || [])].join(' ');
|
|
1440
1448
|
const answer = dialogValue(await tui.askDialog(
|
|
1441
|
-
|
|
1449
|
+
remote
|
|
1450
|
+
? `This project's .mcp.json wants to connect to a remote MCP server "${server.name}". What the agent sends its tools goes to that address.`
|
|
1451
|
+
: `This project's .mcp.json wants to start an MCP server "${server.name}". It runs on your computer, with your files.`,
|
|
1442
1452
|
[{ label: 'Allow and remember for this project', value: 'trust' }, { label: 'Not now', value: 'no' }],
|
|
1443
|
-
|
|
1453
|
+
remote
|
|
1454
|
+
? [`address: ${String(line).slice(0, 300)}`, ...(server.config.headers ? [`headers: ${Object.keys(server.config.headers).join(', ')}`] : [])]
|
|
1455
|
+
: [`command: ${line.slice(0, 300)}`, ...(server.config.env ? [`env: ${Object.keys(server.config.env).join(', ')}`] : [])],
|
|
1444
1456
|
{ title: 'MCP SERVER', hideComment: true },
|
|
1445
1457
|
));
|
|
1446
1458
|
if (answer === 'trust') { trustServer(flags.cwd, server); tui.notice(`MCP server ${server.name} approved for this project.`, 'info'); }
|
|
@@ -1684,15 +1696,32 @@ function appendHistoryLine(line) {
|
|
|
1684
1696
|
} catch { /* history is a convenience, never a reason to fail */ }
|
|
1685
1697
|
}
|
|
1686
1698
|
|
|
1687
|
-
/**
|
|
1688
|
-
|
|
1689
|
-
|
|
1699
|
+
/**
|
|
1700
|
+
* Save a note for the agent: this project's JAITUS.md, or the personal notes sent with
|
|
1701
|
+
* every project. Asked when a dialog can be shown; a script or a plain terminal keeps the
|
|
1702
|
+
* old behaviour (the project file). Resolves 'project' | 'user' | null (not saved).
|
|
1703
|
+
*/
|
|
1704
|
+
async function rememberNote(cwd, note, tui) {
|
|
1705
|
+
let where = 'project';
|
|
1706
|
+
if (tui?.askDialog) {
|
|
1707
|
+
const shown = note.length > 80 ? `${note.slice(0, 79)}…` : note;
|
|
1708
|
+
const answer = await tui.askDialog(`Remember "${shown}" for…`, [
|
|
1709
|
+
{ label: `This project — ${CONTEXT_FILE}`, value: 'project' },
|
|
1710
|
+
{ label: 'All my projects — ~/.jaitus/JAITUS.md', value: 'user' },
|
|
1711
|
+
], [], { title: 'Save a note', hideComment: true });
|
|
1712
|
+
const picked = dialogValue(answer);
|
|
1713
|
+
if (picked !== 'project' && picked !== 'user') { out(c.dim(' Not saved.')); return null; }
|
|
1714
|
+
where = picked;
|
|
1715
|
+
}
|
|
1716
|
+
const file = where === 'user' ? userContextFile() : path.join(cwd, CONTEXT_FILE);
|
|
1690
1717
|
try {
|
|
1691
|
-
|
|
1692
|
-
fs.appendFileSync(file, `- ${note}\n`, 'utf8');
|
|
1718
|
+
appendNote(file, note, where === 'user' ? USER_CONTEXT_HEADER : PROJECT_CONTEXT_HEADER);
|
|
1693
1719
|
} catch (e) {
|
|
1694
|
-
fail(`Could not write ${
|
|
1720
|
+
fail(`Could not write ${file}: ${e.message}`);
|
|
1721
|
+
return null;
|
|
1695
1722
|
}
|
|
1723
|
+
ok(`Remembered ${where === 'user' ? 'for every project (~/.jaitus/JAITUS.md)' : `in ${CONTEXT_FILE}`}: ${c.bold(note.slice(0, 60))}${note.length > 60 ? '…' : ''}`);
|
|
1724
|
+
return where;
|
|
1696
1725
|
}
|
|
1697
1726
|
|
|
1698
1727
|
/** Run a shell command with output streaming to the terminal. Returns the exit code. */
|
|
@@ -1832,6 +1861,7 @@ async function slashCommand(verb, args, ctx) {
|
|
|
1832
1861
|
{ label: 'Messages', value: String(ctx.messages.length) },
|
|
1833
1862
|
{ label: 'Tool results', value: String(count((b) => b.type === 'tool_result')) },
|
|
1834
1863
|
{ label: 'Images', value: String(count((b) => b.type === 'image')) },
|
|
1864
|
+
...(loadUserContext() ? [{ label: 'Personal notes', value: '~/.jaitus/JAITUS.md (loaded)' }] : []),
|
|
1835
1865
|
...(loadContext(ctx.flags.cwd) ? [{ label: 'Project notes', value: `${CONTEXT_FILE} (loaded)` }] : []),
|
|
1836
1866
|
{ gap: true },
|
|
1837
1867
|
{ label: '', value: '/compact summarises it now' },
|
|
@@ -1844,6 +1874,7 @@ async function slashCommand(verb, args, ctx) {
|
|
|
1844
1874
|
[` ${c.dim('assistant turns')}`, String(ctx.messages.filter((m) => m.role === 'assistant').length)],
|
|
1845
1875
|
[` ${c.dim('tool results')}`, String(ctx.messages.reduce((n, m) => n + (Array.isArray(m.content) ? m.content.filter((b) => b.type === 'tool_result').length : 0), 0))],
|
|
1846
1876
|
[` ${c.dim('estimated tokens')}`, tokens.toLocaleString()],
|
|
1877
|
+
...(loadUserContext() ? [[` ${c.dim('personal notes')}`, '~/.jaitus/JAITUS.md (loaded)']] : []),
|
|
1847
1878
|
...(loadContext(ctx.flags.cwd) ? [[` ${c.dim('project notes')}`, `${CONTEXT_FILE} (loaded)`]] : []),
|
|
1848
1879
|
]);
|
|
1849
1880
|
info(`${c.dim('Run')} ${c.bold('/compact')} ${c.dim('to summarise and free context when this grows.')}`);
|
|
@@ -1989,9 +2020,21 @@ async function slashCommand(verb, args, ctx) {
|
|
|
1989
2020
|
|
|
1990
2021
|
case '/memory': {
|
|
1991
2022
|
const note = args.join(' ').trim();
|
|
1992
|
-
if (note) {
|
|
1993
|
-
|
|
1994
|
-
|
|
2023
|
+
if (note) { await rememberNote(ctx.flags.cwd, note, ctx.tui); return null; }
|
|
2024
|
+
// Both halves of what the agent is told, each with where it lives.
|
|
2025
|
+
const personal = loadUserContext();
|
|
2026
|
+
const project = loadContext(ctx.flags.cwd);
|
|
2027
|
+
const lines = [
|
|
2028
|
+
c.bold(`Every project — ${userContextFile()}`),
|
|
2029
|
+
...(personal ? personal.split('\n').map((l) => ` ${l}`) : [c.dim(' (nothing yet)')]),
|
|
2030
|
+
'',
|
|
2031
|
+
c.bold(`This project — ${path.join(ctx.flags.cwd, CONTEXT_FILE)}`),
|
|
2032
|
+
...(project ? project.split('\n').map((l) => ` ${l}`) : [c.dim(' (nothing yet)')]),
|
|
2033
|
+
'',
|
|
2034
|
+
c.dim('Type "# a note" (or /memory a note) to add one; you choose where it goes. Edit the files for anything longer.'),
|
|
2035
|
+
];
|
|
2036
|
+
if (ctx.tui?.showPanel) await ctx.tui.showPanel('Memory', lines);
|
|
2037
|
+
else out(lines.join('\n'));
|
|
1995
2038
|
return null;
|
|
1996
2039
|
}
|
|
1997
2040
|
|
|
@@ -2815,7 +2858,7 @@ const commands = {
|
|
|
2815
2858
|
// In the app view: a popup over the conversation that Esc closes, not rows that
|
|
2816
2859
|
// stay in the transcript for good.
|
|
2817
2860
|
if (tui?.showMeters && !json) {
|
|
2818
|
-
void tui.showMeters('Usage', sections);
|
|
2861
|
+
void tui.showMeters('Usage', sections, { ad: 'usage' });
|
|
2819
2862
|
return 0;
|
|
2820
2863
|
}
|
|
2821
2864
|
present(json, withoutCredits({ ...d, planStatus: u?.planStatus || null }), () => table(sectionsAsTable(sections)));
|
|
@@ -2859,18 +2902,50 @@ const commands = {
|
|
|
2859
2902
|
usage: [
|
|
2860
2903
|
'jaitus mcp list',
|
|
2861
2904
|
'jaitus mcp add [--global] <name> -- <command> [args...]',
|
|
2905
|
+
'jaitus mcp add [--global] --transport http|sse <name> <url> [--header "Name: value"]...',
|
|
2862
2906
|
'jaitus mcp trust <name>',
|
|
2863
2907
|
'jaitus mcp remove <name>',
|
|
2864
2908
|
],
|
|
2865
2909
|
detail: [
|
|
2866
|
-
'
|
|
2867
|
-
'
|
|
2868
|
-
'
|
|
2910
|
+
'Servers from your own ~/.jaitus/mcp.json (--global) and the project\'s .mcp.json, merged, exposed to',
|
|
2911
|
+
'the agent as permission-gated tools. A project\'s servers start only once you approve them.',
|
|
2912
|
+
'Local: put the command after -- so its own flags (-y, --port) are not read as Jaitus flags.',
|
|
2913
|
+
'Remote: an https address (Streamable HTTP, or sse for older servers). Write a token as ${VAR} to',
|
|
2914
|
+
'read it from the environment rather than saving it, e.g. --header "Authorization: Bearer ${API_TOKEN}".',
|
|
2869
2915
|
],
|
|
2870
2916
|
run: (argv, { json, flags }) => {
|
|
2871
2917
|
const global = argv.includes('--global');
|
|
2872
|
-
|
|
2918
|
+
// --transport http|sse and repeated --header/-H "Name: value" (remote servers).
|
|
2919
|
+
let transport = null;
|
|
2920
|
+
const headers = {};
|
|
2921
|
+
const rest = [];
|
|
2922
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
2923
|
+
const a = argv[i];
|
|
2924
|
+
if (a === '--global') continue;
|
|
2925
|
+
if (a === '--transport' || a === '-t') { transport = String(argv[i + 1] || '').toLowerCase(); i += 1; continue; }
|
|
2926
|
+
if (a === '--header' || a === '-H') {
|
|
2927
|
+
const h = String(argv[i + 1] || ''); i += 1;
|
|
2928
|
+
const at = h.indexOf(':');
|
|
2929
|
+
if (at <= 0) { fail(`A header is "Name: value" — got ${JSON.stringify(h)}.`); return 1; }
|
|
2930
|
+
headers[h.slice(0, at).trim()] = h.slice(at + 1).trim();
|
|
2931
|
+
continue;
|
|
2932
|
+
}
|
|
2933
|
+
rest.push(a);
|
|
2934
|
+
}
|
|
2873
2935
|
const [action = 'list', name, command, ...args] = rest;
|
|
2936
|
+
if (action === 'add' && transport && transport !== 'stdio') {
|
|
2937
|
+
if (!['http', 'sse'].includes(transport)) { fail('--transport is http, sse or stdio.'); return 1; }
|
|
2938
|
+
if (!name || !command) { fail('Usage: jaitus mcp add --transport http|sse <name> <url> [--header "Name: value"]'); return 1; }
|
|
2939
|
+
const config = { type: transport, url: command, ...(Object.keys(headers).length ? { headers } : {}) };
|
|
2940
|
+
if (!isRemote(config)) { fail('Give the server address, e.g. https://example.com/mcp'); return 1; }
|
|
2941
|
+
try { new URL(expandEnv(command)); } catch { fail(`Not a valid address: ${command}`); return 1; }
|
|
2942
|
+
addMcpServer(flags.cwd, name, config, { global });
|
|
2943
|
+
ok(`Added remote MCP server ${c.bold(name)} (${transport})${global ? ' for every project' : ''}.`);
|
|
2944
|
+
if (Object.values(headers).some((v) => /[A-Za-z0-9_-]{24,}/.test(v) && !/\$\{/.test(v))) {
|
|
2945
|
+
warn(`The header holds what looks like a token, saved as text in ${global ? '~/.jaitus/mcp.json' : '.mcp.json'}. Prefer \${VAR} so it is read from the environment${global ? '' : ' — and never commit it'}.`);
|
|
2946
|
+
}
|
|
2947
|
+
return 0;
|
|
2948
|
+
}
|
|
2874
2949
|
if (action === 'list') {
|
|
2875
2950
|
const servers = listMcpServers(flags.cwd);
|
|
2876
2951
|
present(json, { servers }, () => {
|
|
@@ -2888,7 +2963,7 @@ const commands = {
|
|
|
2888
2963
|
const server = allServers(flags.cwd).find((s) => s.name === name);
|
|
2889
2964
|
if (!server) { warn(`No MCP server named ${name}.`); return 1; }
|
|
2890
2965
|
trustServer(flags.cwd, server);
|
|
2891
|
-
ok(`Approved ${c.bold(name)} for this project: ${[server.config.command, ...(server.config.args || [])].join(' ')}`);
|
|
2966
|
+
ok(`Approved ${c.bold(name)} for this project: ${isRemote(server.config) ? server.config.url : [server.config.command, ...(server.config.args || [])].join(' ')}`);
|
|
2892
2967
|
return 0;
|
|
2893
2968
|
}
|
|
2894
2969
|
if (action === 'remove' && name) {
|
|
@@ -2896,7 +2971,7 @@ const commands = {
|
|
|
2896
2971
|
ok(`Removed MCP server ${c.bold(name)}.`);
|
|
2897
2972
|
return 0;
|
|
2898
2973
|
}
|
|
2899
|
-
fail('Usage: jaitus mcp list | add [--global] <name> -- <command> [args...] | trust <name> | remove <name>');
|
|
2974
|
+
fail('Usage: jaitus mcp list | add [--global] <name> -- <command> [args...] | add --transport http|sse <name> <url> | trust <name> | remove <name>');
|
|
2900
2975
|
return 1;
|
|
2901
2976
|
},
|
|
2902
2977
|
},
|
package/src/mcp.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// ─── Model Context Protocol
|
|
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:
|
|
4
|
-
// stdio
|
|
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
|
-
/**
|
|
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 =
|
|
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?.
|
|
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;
|