@nonbot/cli 0.10.15 → 0.10.17

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,232 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { compareVersions, parseSemver } from './update-check.js';
5
+ export function tokenFor(entry, spec, cliAuth) {
6
+ if (spec) {
7
+ const auth = Object.entries(spec.headers).find(([k]) => k.toLowerCase() === 'authorization')?.[1];
8
+ const bearer = auth && /^Bearer\s+(\S+)$/i.exec(auth)?.[1];
9
+ if (bearer)
10
+ return { source: 'config', token: bearer };
11
+ const env = spec.env.NON_BOT_API_TOKEN;
12
+ if (env)
13
+ return { source: 'config', token: env };
14
+ }
15
+ if (entry.kind === 'remote')
16
+ return { source: 'none' };
17
+ return cliAuth?.pat ? { source: 'login', token: cliAuth.pat } : { source: 'none' };
18
+ }
19
+ export function maskToken(token) {
20
+ const tail = token.slice(-4);
21
+ return token.startsWith('pat_') ? `pat_…${tail}` : `…${tail}`;
22
+ }
23
+ export function isReadOnly(spec) {
24
+ if (!spec)
25
+ return false;
26
+ if (spec.env.NON_BOT_MCP_READONLY === '1')
27
+ return true;
28
+ if (spec.url) {
29
+ try {
30
+ return new URL(spec.url).searchParams.get('readonly') === '1';
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ return false;
37
+ }
38
+ export async function checkToken(baseUrl, token, fetchImpl = fetch) {
39
+ try {
40
+ const res = await fetchImpl(`${baseUrl}/api/auth/me`, {
41
+ headers: { Authorization: `Bearer ${token}`, 'X-Requested-With': 'ConradPM-Native' },
42
+ signal: AbortSignal.timeout(8_000),
43
+ });
44
+ if (res.status === 401)
45
+ return { valid: false, scopes: null };
46
+ if (!res.ok)
47
+ return { valid: null, scopes: null };
48
+ const body = (await res.json());
49
+ if (!body.authenticated)
50
+ return { valid: false, scopes: null };
51
+ const t = body.token;
52
+ return {
53
+ valid: true,
54
+ name: typeof t?.name === 'string' ? t.name : null,
55
+ scopes: Array.isArray(t?.scopes) ? t.scopes.filter((s) => typeof s === 'string') : null,
56
+ };
57
+ }
58
+ catch {
59
+ return { valid: null, scopes: null };
60
+ }
61
+ }
62
+ export function familyOf(tool) {
63
+ if (tool.startsWith('jobshop_'))
64
+ return { family: 'jobshop', resources: ['jobshop'] };
65
+ if (tool.startsWith('radbug_'))
66
+ return { family: 'radbugs', resources: ['radbugs', 'admin', 'team'] };
67
+ if (tool.startsWith('run_'))
68
+ return { family: 'run', resources: ['portfolio', 'runs'] };
69
+ return { family: 'portfolio', resources: ['portfolio'] };
70
+ }
71
+ export function scopeGaps(scopes, tools) {
72
+ if (scopes === null || scopes.includes('*'))
73
+ return { full: true, blocked: [], readOnly: [] };
74
+ const has = (res) => scopes.some((s) => s.startsWith(`${res}:`));
75
+ const canWrite = (res) => scopes.some((s) => s === `${res}:write` || (res === 'runs' && /^runs:(fire|control|respond)$/.test(s)));
76
+ const counts = new Map();
77
+ for (const t of tools) {
78
+ const f = familyOf(t.name);
79
+ const c = counts.get(f.family) ?? { resources: f.resources, n: 0 };
80
+ c.n++;
81
+ counts.set(f.family, c);
82
+ }
83
+ const blocked = [];
84
+ const readOnly = [];
85
+ for (const [family, { resources, n }] of counts) {
86
+ if (!resources.some(has))
87
+ blocked.push({ family, tools: n, needs: resources.map((r) => `${r}:*`).join(' or ') });
88
+ else if (!resources.some(canWrite))
89
+ readOnly.push({ family, tools: n });
90
+ }
91
+ return { full: false, blocked, readOnly };
92
+ }
93
+ export function preApprovals(serverName, dirs, home = homedir(), read = readIfExists) {
94
+ const files = [join(home, '.claude', 'settings.json'), join(home, '.claude', 'settings.local.json')];
95
+ for (const d of dirs)
96
+ files.push(join(d, '.claude', 'settings.json'), join(d, '.claude', 'settings.local.json'));
97
+ const whole = new Set([`mcp__${serverName}`, `mcp__${serverName}__*`]);
98
+ const prefix = `mcp__${serverName}__`;
99
+ const out = { all: false, some: 0, denied: 0, files: [] };
100
+ const allowed = new Set();
101
+ const denied = new Set();
102
+ for (const f of [...new Set(files)]) {
103
+ let json = null;
104
+ try {
105
+ const text = read(f);
106
+ json = text ? JSON.parse(text) : null;
107
+ }
108
+ catch {
109
+ json = null;
110
+ }
111
+ const allow = Array.isArray(json?.permissions?.allow) ? json.permissions.allow : [];
112
+ const deny = Array.isArray(json?.permissions?.deny) ? json.permissions.deny : [];
113
+ let said = false;
114
+ for (const r of allow) {
115
+ if (typeof r !== 'string')
116
+ continue;
117
+ if (whole.has(r)) {
118
+ out.all = true;
119
+ said = true;
120
+ }
121
+ else if (r.startsWith(prefix)) {
122
+ allowed.add(r);
123
+ said = true;
124
+ }
125
+ }
126
+ for (const r of deny) {
127
+ if (typeof r !== 'string')
128
+ continue;
129
+ if (whole.has(r)) {
130
+ out.denied = 'all';
131
+ said = true;
132
+ }
133
+ else if (r.startsWith(prefix) && out.denied !== 'all') {
134
+ denied.add(r);
135
+ said = true;
136
+ }
137
+ }
138
+ if (said)
139
+ out.files.push(f.startsWith(home) ? '~' + f.slice(home.length) : f);
140
+ }
141
+ out.some = allowed.size;
142
+ if (out.denied !== 'all')
143
+ out.denied = denied.size;
144
+ return out;
145
+ }
146
+ function readIfExists(p) {
147
+ try {
148
+ return existsSync(p) ? readFileSync(p, 'utf-8') : null;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ const RUN_PORTFOLIO_TOOLS = new Set([
155
+ 'portfolio_create_activation',
156
+ 'portfolio_get_activation',
157
+ 'portfolio_update_activation_status',
158
+ 'portfolio_subscribe_to_activations',
159
+ 'portfolio_attach_brief',
160
+ 'portfolio_link_chat_session',
161
+ 'portfolio_mark_run_ready',
162
+ 'portfolio_repo_path',
163
+ ]);
164
+ export function appOfTool(tool) {
165
+ if (tool.startsWith('run_') || RUN_PORTFOLIO_TOOLS.has(tool))
166
+ return 'run';
167
+ if (tool.startsWith('jobshop_'))
168
+ return 'jobshop';
169
+ return null;
170
+ }
171
+ export async function fetchAppStates(baseUrl, token, fetchImpl = fetch) {
172
+ try {
173
+ const res = await fetchImpl(`${baseUrl}/api/feature-flags/me`, {
174
+ headers: { Authorization: `Bearer ${token}`, 'X-Requested-With': 'ConradPM-Native' },
175
+ signal: AbortSignal.timeout(8_000),
176
+ });
177
+ if (!res.ok)
178
+ return null;
179
+ const body = (await res.json());
180
+ if (!Array.isArray(body.apps))
181
+ return null;
182
+ return body.apps
183
+ .filter((a) => !!a && typeof a.id === 'string')
184
+ .map((a) => ({ id: a.id, installed: !!a.installed, enabled: !!a.enabled, available: a.available }));
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+ export function appsOff(tools, apps) {
191
+ const out = [];
192
+ for (const app of ['run', 'jobshop']) {
193
+ const n = tools.filter((t) => appOfTool(t.name) === app).length;
194
+ if (n === 0)
195
+ continue;
196
+ const s = apps.find((a) => a.id === app);
197
+ if (!s)
198
+ continue;
199
+ if (s.available === false)
200
+ out.push({ app, tools: n, state: 'not offered' });
201
+ else if (!s.installed)
202
+ out.push({ app, tools: n, state: 'not added' });
203
+ else if (!s.enabled)
204
+ out.push({ app, tools: n, state: 'off' });
205
+ }
206
+ return out;
207
+ }
208
+ export async function newerReleases(installed, latest, fetchImpl = fetch) {
209
+ if (!parseSemver(installed) || !parseSemver(latest) || compareVersions(latest, installed) <= 0)
210
+ return [];
211
+ try {
212
+ const res = await fetchImpl('https://registry.npmjs.org/@nonbot/mcp', {
213
+ headers: { Accept: 'application/json' },
214
+ signal: AbortSignal.timeout(8_000),
215
+ });
216
+ if (!res.ok)
217
+ return null;
218
+ const doc = (await res.json());
219
+ const out = [];
220
+ for (const [v, t] of Object.entries(doc.time ?? {})) {
221
+ if (!parseSemver(v) || /-/.test(v))
222
+ continue;
223
+ if (compareVersions(v, installed) > 0 && compareVersions(v, latest) <= 0 && typeof t === 'string') {
224
+ out.push({ version: v, date: t.slice(0, 10) });
225
+ }
226
+ }
227
+ return out.sort((a, b) => compareVersions(a.version, b.version));
228
+ }
229
+ catch {
230
+ return null;
231
+ }
232
+ }
@@ -1,6 +1,68 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, dirname, join, resolve, isAbsolute } from 'node:path';
4
+ export const LEGACY_MCP_NAMES = ['portfolio-mcp', 'context-graph', 'jobshop-mcp'];
5
+ const LAUNCH = new WeakMap();
6
+ export function launchSpecOf(entry) {
7
+ return LAUNCH.get(entry);
8
+ }
9
+ export function isNonbotRemoteUrl(value) {
10
+ if (!value)
11
+ return false;
12
+ try {
13
+ const u = new URL(value);
14
+ return u.protocol === 'https:' && u.hostname.toLowerCase().replace(/\.$/, '') === 'mcp.non.bot';
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ export function projectServerApproved(name, dir, claudeJson, home, read) {
21
+ const enabled = new Set();
22
+ const disabled = new Set();
23
+ let all = false;
24
+ const takeFrom = (o, mayApprove) => {
25
+ if (!o || typeof o !== 'object')
26
+ return;
27
+ const r = o;
28
+ if (Array.isArray(r.disabledMcpjsonServers))
29
+ for (const n of r.disabledMcpjsonServers)
30
+ if (typeof n === 'string')
31
+ disabled.add(n);
32
+ if (!mayApprove)
33
+ return;
34
+ if (Array.isArray(r.enabledMcpjsonServers))
35
+ for (const n of r.enabledMcpjsonServers)
36
+ if (typeof n === 'string')
37
+ enabled.add(n);
38
+ if (r.enableAllProjectMcpServers === true)
39
+ all = true;
40
+ };
41
+ let real = dir;
42
+ try {
43
+ real = realpathSync(dir);
44
+ }
45
+ catch {
46
+ }
47
+ takeFrom(claudeJson?.projects?.[dir], true);
48
+ if (real !== dir)
49
+ takeFrom(claudeJson?.projects?.[real], true);
50
+ takeFrom(parse(read(join(home, '.claude', 'settings.json'))), true);
51
+ takeFrom(parse(read(join(dir, '.claude', 'settings.json'))), false);
52
+ takeFrom(parse(read(join(dir, '.claude', 'settings.local.json'))), false);
53
+ if (disabled.has(name))
54
+ return false;
55
+ return enabled.has(name) || all;
56
+ }
57
+ function stringMap(v) {
58
+ const out = {};
59
+ if (v && typeof v === 'object') {
60
+ for (const [k, val] of Object.entries(v))
61
+ if (typeof val === 'string')
62
+ out[k] = val;
63
+ }
64
+ return out;
65
+ }
4
66
  function defaultReadFile(path) {
5
67
  try {
6
68
  return existsSync(path) ? readFileSync(path, 'utf-8') : null;
@@ -16,7 +78,7 @@ export function classifyServer(name, server, baseDir) {
16
78
  const args = Array.isArray(server.args) ? server.args.filter((a) => typeof a === 'string') : [];
17
79
  const command = typeof server.command === 'string' ? server.command : '';
18
80
  const url = typeof server.url === 'string' ? server.url : '';
19
- if (/(^|\/\/|\.)mcp\.non\.bot\b/.test(url) || args.some((a) => /mcp\.non\.bot/.test(a))) {
81
+ if (isNonbotRemoteUrl(url) || args.some(isNonbotRemoteUrl)) {
20
82
  return { kind: 'remote' };
21
83
  }
22
84
  const spec = args.find((a) => /^@nonbot\/mcp(@[^\s]+)?$/.test(a));
@@ -61,25 +123,38 @@ export function scanMcpConfigs(deps = {}) {
61
123
  const names = new Set();
62
124
  const where = new Map();
63
125
  const nonbot = [];
126
+ const locations = [];
64
127
  const seenFiles = new Set();
65
- const take = (source, json, baseDir) => {
128
+ const take = (source, json, baseDir, scope, file, dir) => {
66
129
  for (const [name, server] of serversIn(json)) {
67
130
  names.add(name);
68
131
  if (!where.has(name))
69
132
  where.set(name, source);
133
+ locations.push({ name, source, scope, file, ...(dir ? { dir } : {}) });
70
134
  const c = classifyServer(name, server, baseDir);
71
- if (c)
72
- nonbot.push({ source, name, ...c });
135
+ if (c) {
136
+ const approved = scope !== 'project' || (!!dir && projectServerApproved(name, dir, claudeJson, home, read));
137
+ const entry = { source, name, ...c, scope, ...(dir ? { dir } : {}), approved };
138
+ nonbot.push(entry);
139
+ LAUNCH.set(entry, {
140
+ command: typeof server.command === 'string' ? server.command : undefined,
141
+ args: Array.isArray(server.args) ? server.args.filter((a) => typeof a === 'string') : [],
142
+ env: stringMap(server.env),
143
+ url: typeof server.url === 'string' ? server.url : undefined,
144
+ headers: stringMap(server.headers),
145
+ cwd: baseDir,
146
+ });
147
+ }
73
148
  }
74
149
  };
75
150
  const claudeJsonPath = join(home, '.claude.json');
76
151
  const claudeJson = parse(read(claudeJsonPath));
77
152
  const projectDirs = [];
78
153
  if (claudeJson) {
79
- take('~/.claude.json', claudeJson, home);
154
+ take('~/.claude.json', claudeJson, home, 'user', claudeJsonPath);
80
155
  if (claudeJson.projects && typeof claudeJson.projects === 'object') {
81
156
  for (const [dir, proj] of Object.entries(claudeJson.projects)) {
82
- take(`~/.claude.json (${tilde(dir, home)})`, proj, dir);
157
+ take(`~/.claude.json (${tilde(dir, home)})`, proj, dir, 'local', claudeJsonPath, dir);
83
158
  projectDirs.push(dir);
84
159
  }
85
160
  }
@@ -98,15 +173,18 @@ export function scanMcpConfigs(deps = {}) {
98
173
  seenFiles.add(file);
99
174
  const json = parse(read(file));
100
175
  if (json)
101
- take(tilde(file, home), json, dir);
176
+ take(tilde(file, home), json, dir, 'project', file, dir);
102
177
  }
103
178
  if (platform === 'darwin') {
104
- const desktop = join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
179
+ const desktop = desktopConfigPath(home);
105
180
  const json = parse(read(desktop));
106
181
  if (json)
107
- take('Claude Desktop', json, home);
182
+ take('Claude Desktop', json, home, 'desktop', desktop);
108
183
  }
109
- return { names, where, nonbot };
184
+ return { names, where, nonbot, locations };
185
+ }
186
+ export function desktopConfigPath(home = homedir()) {
187
+ return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
110
188
  }
111
189
  export function describeNonbotMcp(e, home = homedir()) {
112
190
  switch (e.kind) {
@@ -0,0 +1,226 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { VERSION } from '../version.js';
3
+ import { isNonbotRemoteUrl } from './mcp-config.js';
4
+ export const MCP_PROTOCOL_VERSION = '2025-06-18';
5
+ const INIT_PARAMS = {
6
+ protocolVersion: MCP_PROTOCOL_VERSION,
7
+ capabilities: {},
8
+ clientInfo: { name: 'nonbot-cli', version: VERSION },
9
+ };
10
+ const LOADER_ENV = /^(NODE_OPTIONS|NODE_PATH|BASH_ENV|ENV|PROMPT_COMMAND|DYLD_.*|LD_.*)$/i;
11
+ export function safeConfigEnv(env) {
12
+ const out = {};
13
+ for (const [k, v] of Object.entries(env))
14
+ if (!LOADER_ENV.test(k))
15
+ out[k] = v;
16
+ return out;
17
+ }
18
+ function toolsFrom(result) {
19
+ const r = (result ?? {});
20
+ const tools = Array.isArray(r.tools) ? r.tools.filter((t) => t && typeof t === 'object') : [];
21
+ return { tools, next: typeof r.nextCursor === 'string' && r.nextCursor ? r.nextCursor : undefined };
22
+ }
23
+ function serverInfo(result) {
24
+ const info = (result ?? {}).serverInfo ?? {};
25
+ return {
26
+ name: typeof info.name === 'string' ? info.name.slice(0, 80) : undefined,
27
+ version: typeof info.version === 'string' ? info.version.slice(0, 40) : undefined,
28
+ };
29
+ }
30
+ function finish(started, now, parts) {
31
+ return { ok: false, ms: now() - started, tools: [], toolBytes: 0, ...parts };
32
+ }
33
+ export async function probeStdio(spec, deps = {}) {
34
+ const now = deps.now ?? (() => Date.now());
35
+ const started = now();
36
+ if (!spec.command)
37
+ return finish(started, now, { error: 'no command in the config' });
38
+ const spawnImpl = deps.spawnImpl ?? ((cmd, args, opts) => nodeSpawn(cmd, args, { ...opts, stdio: ['pipe', 'pipe', 'ignore'], shell: process.platform === 'win32' }));
39
+ const timeoutMs = deps.timeoutMs ?? 45_000;
40
+ let child;
41
+ try {
42
+ child = spawnImpl(spec.command, spec.args, { cwd: spec.cwd, env: { ...process.env, ...safeConfigEnv(spec.env) } });
43
+ }
44
+ catch {
45
+ return finish(started, now, { error: `could not start "${spec.command}"` });
46
+ }
47
+ return new Promise((resolve) => {
48
+ let done = false;
49
+ let buf = '';
50
+ let nextId = 1;
51
+ const waiting = new Map();
52
+ const end = (r) => {
53
+ if (done)
54
+ return;
55
+ done = true;
56
+ clearTimeout(timer);
57
+ try {
58
+ child.stdin?.end();
59
+ }
60
+ catch { }
61
+ try {
62
+ child.kill('SIGTERM');
63
+ }
64
+ catch { }
65
+ resolve(r);
66
+ };
67
+ const timer = setTimeout(() => end(finish(started, now, { error: `no answer within ${Math.round(timeoutMs / 1000)}s` })), timeoutMs);
68
+ child.on('error', () => end(finish(started, now, { error: `could not start "${spec.command}"` })));
69
+ child.on('exit', (code) => end(finish(started, now, { error: `it exited (code ${code ?? '?'}) before answering` })));
70
+ child.stdout?.setEncoding('utf-8');
71
+ child.stdout?.on('data', (chunk) => {
72
+ buf += chunk;
73
+ if (buf.length > 8_000_000)
74
+ return end(finish(started, now, { error: 'answer too large' }));
75
+ let nl;
76
+ while ((nl = buf.indexOf('\n')) >= 0) {
77
+ const line = buf.slice(0, nl).trim();
78
+ buf = buf.slice(nl + 1);
79
+ if (!line)
80
+ continue;
81
+ let msg;
82
+ try {
83
+ msg = JSON.parse(line);
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ if (typeof msg.id === 'number' && waiting.has(msg.id)) {
89
+ const cb = waiting.get(msg.id);
90
+ waiting.delete(msg.id);
91
+ cb(msg);
92
+ }
93
+ }
94
+ });
95
+ const send = (method, params) => new Promise((res) => {
96
+ const id = nextId++;
97
+ waiting.set(id, res);
98
+ child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) }) + '\n');
99
+ });
100
+ const notify = (method) => child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method }) + '\n');
101
+ (async () => {
102
+ const init = await send('initialize', INIT_PARAMS);
103
+ if (init.error)
104
+ return end(finish(started, now, { error: 'it refused the handshake' }));
105
+ const info = serverInfo(init.result);
106
+ notify('notifications/initialized');
107
+ const all = [];
108
+ let cursor;
109
+ for (let page = 0; page < 20; page++) {
110
+ const res = await send('tools/list', cursor ? { cursor } : undefined);
111
+ if (res.error)
112
+ return end(finish(started, now, { serverName: info.name, serverVersion: info.version, error: 'tools/list failed' }));
113
+ const { tools, next } = toolsFrom(res.result);
114
+ all.push(...tools);
115
+ if (!next)
116
+ break;
117
+ cursor = next;
118
+ }
119
+ end({
120
+ ok: true,
121
+ ms: now() - started,
122
+ serverName: info.name,
123
+ serverVersion: info.version,
124
+ tools: all.map((t) => ({ name: String(t.name ?? '') })).filter((t) => t.name),
125
+ toolBytes: Buffer.byteLength(JSON.stringify({ tools: all })),
126
+ });
127
+ })().catch(() => end(finish(started, now, { error: 'unexpected answer' })));
128
+ });
129
+ }
130
+ export async function probeRemote(spec, deps = {}) {
131
+ const now = deps.now ?? (() => Date.now());
132
+ const fetchImpl = deps.fetchImpl ?? fetch;
133
+ const started = now();
134
+ if (!spec.url)
135
+ return finish(started, now, { error: 'no url in the config' });
136
+ const headers = {
137
+ 'Content-Type': 'application/json',
138
+ Accept: 'application/json, text/event-stream',
139
+ ...spec.headers,
140
+ };
141
+ if (!Object.keys(headers).some((k) => k.toLowerCase() === 'authorization') && deps.fallbackBearer && isNonbotRemoteUrl(spec.url)) {
142
+ headers.Authorization = `Bearer ${deps.fallbackBearer}`;
143
+ }
144
+ let nextId = 1;
145
+ const call = async (method, params, isNotification = false) => {
146
+ const body = isNotification
147
+ ? { jsonrpc: '2.0', method }
148
+ : { jsonrpc: '2.0', id: nextId++, method, ...(params ? { params } : {}) };
149
+ const res = await fetchImpl(spec.url, {
150
+ method: 'POST',
151
+ headers,
152
+ body: JSON.stringify(body),
153
+ redirect: 'manual',
154
+ signal: AbortSignal.timeout(deps.timeoutMs ?? 15_000),
155
+ });
156
+ const session = res.headers.get('mcp-session-id');
157
+ if (session)
158
+ headers['Mcp-Session-Id'] = session;
159
+ if (isNotification)
160
+ return null;
161
+ if (res.status === 401 || res.status === 403)
162
+ throw new Error(`auth ${res.status}`);
163
+ if (!res.ok)
164
+ throw new Error(`HTTP ${res.status}`);
165
+ const text = await res.text();
166
+ if ((res.headers.get('content-type') ?? '').includes('text/event-stream')) {
167
+ for (const line of text.split('\n')) {
168
+ if (!line.startsWith('data:'))
169
+ continue;
170
+ try {
171
+ const msg = JSON.parse(line.slice(5).trim());
172
+ if (msg.result !== undefined || msg.error)
173
+ return msg;
174
+ }
175
+ catch { }
176
+ }
177
+ return { error: { message: 'no result' } };
178
+ }
179
+ return JSON.parse(text);
180
+ };
181
+ try {
182
+ const init = await call('initialize', INIT_PARAMS);
183
+ if (!init || init.error)
184
+ return finish(started, now, { error: 'it refused the handshake' });
185
+ const info = serverInfo(init.result);
186
+ await call('notifications/initialized', undefined, true).catch(() => null);
187
+ const all = [];
188
+ let cursor;
189
+ for (let page = 0; page < 20; page++) {
190
+ const res = await call('tools/list', cursor ? { cursor } : undefined);
191
+ if (!res || res.error)
192
+ return finish(started, now, { serverName: info.name, serverVersion: info.version, error: 'tools/list failed' });
193
+ const { tools, next } = toolsFrom(res.result);
194
+ all.push(...tools);
195
+ if (!next)
196
+ break;
197
+ cursor = next;
198
+ }
199
+ return {
200
+ ok: true,
201
+ ms: now() - started,
202
+ serverName: info.name,
203
+ serverVersion: info.version,
204
+ tools: all.map((t) => ({ name: String(t.name ?? '') })).filter((t) => t.name),
205
+ toolBytes: Buffer.byteLength(JSON.stringify({ tools: all })),
206
+ };
207
+ }
208
+ catch (e) {
209
+ const m = e.message;
210
+ return finish(started, now, { error: /^auth/.test(m) ? 'the server refused the token' : /^HTTP/.test(m) ? `the server answered ${m}` : 'could not reach it' });
211
+ }
212
+ }
213
+ export function probeMcp(spec, deps = {}) {
214
+ return spec.url && !spec.command ? probeRemote(spec, deps) : probeStdio(spec, deps);
215
+ }
216
+ export function toolGroups(tools) {
217
+ const counts = new Map();
218
+ for (const t of tools) {
219
+ const g = t.name.includes('_') ? t.name.slice(0, t.name.indexOf('_')) : t.name;
220
+ counts.set(g, (counts.get(g) ?? 0) + 1);
221
+ }
222
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
223
+ }
224
+ export function estimateTokens(bytes) {
225
+ return Math.round(bytes / 4);
226
+ }
@@ -0,0 +1,63 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync, chmodSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ function configDir() {
6
+ const override = process.env.NONBOT_CONFIG_DIR;
7
+ return override && override.length > 0 ? override : join(homedir(), '.config', 'nonbot');
8
+ }
9
+ export function trustFilePath(dir = configDir()) {
10
+ return join(dir, 'mcp-trust.json');
11
+ }
12
+ function realDir(dir) {
13
+ try {
14
+ return realpathSync(dir);
15
+ }
16
+ catch {
17
+ return dir;
18
+ }
19
+ }
20
+ export function launchFingerprint(spec) {
21
+ const env = Object.keys(spec.env).sort().map((k) => [k, spec.env[k]]);
22
+ const payload = JSON.stringify({ command: spec.command ?? null, args: spec.args, url: spec.url ?? null, env, cwd: realDir(spec.cwd) });
23
+ return createHash('sha256').update(payload).digest('hex');
24
+ }
25
+ function readRecords(file) {
26
+ try {
27
+ if (!existsSync(file))
28
+ return [];
29
+ const json = JSON.parse(readFileSync(file, 'utf-8'));
30
+ return Array.isArray(json.approved)
31
+ ? json.approved.filter((r) => r && typeof r === 'object' && typeof r.fingerprint === 'string')
32
+ : [];
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ export function isRememberedTrust(entry, spec, file = trustFilePath()) {
39
+ if (!entry.dir)
40
+ return false;
41
+ const dir = realDir(entry.dir);
42
+ const fp = launchFingerprint(spec);
43
+ return readRecords(file).some((r) => r.dir === dir && r.name === entry.name && r.fingerprint === fp);
44
+ }
45
+ export function mayStartEntry(entry, spec, isRemembered = (e, s) => isRememberedTrust(e, s)) {
46
+ if (entry.approved !== false)
47
+ return true;
48
+ return isRemembered(entry, spec);
49
+ }
50
+ export function rememberTrust(entry, spec, file = trustFilePath(), now = new Date()) {
51
+ if (!entry.dir)
52
+ return;
53
+ const dir = realDir(entry.dir);
54
+ const records = readRecords(file).filter((r) => !(r.dir === dir && r.name === entry.name));
55
+ records.push({ dir, name: entry.name, fingerprint: launchFingerprint(spec), approvedAt: now.toISOString() });
56
+ const parent = join(file, '..');
57
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
58
+ const tmp = `${file}.tmp`;
59
+ writeFileSync(tmp, JSON.stringify({ approved: records.slice(-200) }, null, 2) + '\n', { mode: 0o600 });
60
+ chmodSync(tmp, 0o600);
61
+ renameSync(tmp, file);
62
+ chmodSync(file, 0o600);
63
+ }