@josfox/jos 4.0.8 → 5.0.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.
- package/LICENSE +208 -21
- package/PROMISES.md +52 -0
- package/README.md +294 -146
- package/SECURITY-MCP.md +117 -0
- package/SECURITY-SECRETS.md +108 -0
- package/STYLE.md +23 -0
- package/bin/jos +139 -67
- package/bin/jos.cmd +5 -0
- package/examples/01-hello.jos +44 -0
- package/examples/02-criteria.jos +54 -0
- package/examples/03-flow-gate.jos +83 -0
- package/examples/04-use-template/README.md +22 -0
- package/examples/04-use-template/lib/greeting.jos +56 -0
- package/examples/04-use-template/use.jos +64 -0
- package/examples/05-sealed/.jos/manifest.jos +53 -0
- package/examples/05-sealed/README.md +25 -0
- package/examples/06-requires.jos +42 -0
- package/examples/07-sandbox.jos +39 -0
- package/examples/08-ids-ledger-meter.md +26 -0
- package/examples/10-ollama.jos +47 -0
- package/examples/11-openai.jos +47 -0
- package/examples/12-gemini.jos +47 -0
- package/examples/13-anthropic.jos +47 -0
- package/examples/14-serve.jos +88 -0
- package/examples/README.md +105 -0
- package/examples/hello.jos +47 -0
- package/package.json +31 -9
- package/promises.json +894 -0
- package/src/args.js +47 -0
- package/src/commands/_frozen/add.README.md +1 -0
- package/src/commands/_frozen/get.README.md +1 -0
- package/src/commands/_frozen/llm.README.md +1 -0
- package/src/commands/_frozen/prompts.README.md +1 -0
- package/src/commands/_frozen/repo.README.md +1 -0
- package/src/commands/_frozen/serve-explorer.README.md +1 -0
- package/src/commands/ids.js +101 -0
- package/src/commands/init.js +113 -147
- package/src/commands/ledger.js +94 -0
- package/src/commands/meter.js +96 -0
- package/src/commands/migrate.js +214 -0
- package/src/commands/overview.js +118 -0
- package/src/commands/run.js +164 -214
- package/src/commands/runs.js +102 -0
- package/src/commands/secrets.js +695 -115
- package/src/commands/start.js +210 -0
- package/src/commands/validate.js +153 -165
- package/src/commands/verify.js +56 -0
- package/src/commands/version.js +23 -0
- package/src/intention.js +114 -0
- package/src/kernel.js +69 -0
- package/src/ledgers.js +57 -0
- package/src/mcp-guide.js +138 -0
- package/src/mcp.js +734 -0
- package/src/serve.js +38 -770
- package/src/ui.js +98 -0
- package/src/commands/add.js +0 -145
- package/src/commands/get.js +0 -245
- package/src/commands/repo.js +0 -139
- package/src/index.js +0 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JOS MCP Server - Model Context Protocol Implementation
|
|
3
|
+
* JSON-RPC 2.0 over stdio for AI assistant integration
|
|
4
|
+
*
|
|
5
|
+
* Security model (SECURITY-MCP.md):
|
|
6
|
+
* - One root: the working directory, or the directory given with --root (its real path). The server refuses to
|
|
7
|
+
* start when the root would be "/", the home directory or an ancestor of it, unless --root says so explicitly.
|
|
8
|
+
* ~/.jos is no longer a root of its own.
|
|
9
|
+
* - Read-only by default: write_file exists only with --allow-write, and never writes under ~/.jos.
|
|
10
|
+
* - Every path is resolved to its real path (fs.realpathSync.native; for a path that does not exist yet, the real
|
|
11
|
+
* path of its deepest existing ancestor) before it is compared with the root, and the file is then opened by that
|
|
12
|
+
* real path with O_NOFOLLOW. `..`, symlinks, repeated slashes and other spellings all end at the same real path.
|
|
13
|
+
* - ~/.jos/secrets and everything in it, and any `.master` or `vault.json` under ~/.jos, are denied for reading,
|
|
14
|
+
* writing, listing and searching, compared without regard to case (APFS and NTFS ignore case), whether the path
|
|
15
|
+
* is spelled out or reached through a symlink. Besides the real path, the device and inode of the path and of
|
|
16
|
+
* each of its ancestors are compared with ~/.jos/secrets and its entries (hard links, APFS case folding).
|
|
17
|
+
* - Out of root and protected give the same error, whether the file exists or not; no error carries file content.
|
|
18
|
+
* - The module tools (list_modules, hot_reload, list_processes) only read fixed files under ~/.jos (modules.json,
|
|
19
|
+
* modules/, runs/) with the same denial: a link there into secrets reads as missing. toggle_module, which wrote
|
|
20
|
+
* ~/.jos/modules.json, is removed.
|
|
21
|
+
* - run_command is off unless the server was started with `jos serve --mcp --allow-shell`: it is not listed and a
|
|
22
|
+
* call is an unknown tool. With the flag it starts a program with an argv array (no shell), in a cwd inside the
|
|
23
|
+
* root (never "/"), every command is announced on stderr and the child's environment drops every variable named
|
|
24
|
+
* like a secret in ~/.jos/secrets (vault.json and keychain.json). It still runs as the user and can read
|
|
25
|
+
* ~/.jos/secrets itself (`cat`). That is why it is off by default.
|
|
26
|
+
*/
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const { spawnSync } = require('child_process');
|
|
32
|
+
const readline = require('readline');
|
|
33
|
+
const ui = require('./ui');
|
|
34
|
+
const guide = require('./mcp-guide');
|
|
35
|
+
|
|
36
|
+
// MCP Protocol version
|
|
37
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
38
|
+
const SERVER_NAME = 'jos-mcp-server';
|
|
39
|
+
const SERVER_VERSION = '1.0.0';
|
|
40
|
+
const SHELL_TOOL = 'run_command';
|
|
41
|
+
// Tools that exist only with a flag: tool name -> context switch.
|
|
42
|
+
const GATED = { run_command: 'allowShell', write_file: 'allowWrite' };
|
|
43
|
+
|
|
44
|
+
// Flags a platform lacks (Windows) are 0: `undefined | 0` is 0.
|
|
45
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW | 0;
|
|
46
|
+
const O_NONBLOCK = fs.constants.O_NONBLOCK | 0; // a FIFO in the root must not hang the server
|
|
47
|
+
const MAX_READ = 10 * 1024 * 1024;
|
|
48
|
+
const MAX_TIMEOUT = 10 * 60 * 1000;
|
|
49
|
+
const DENIED = 'Access denied: path is outside the working directory or protected';
|
|
50
|
+
const SHELL_REFUSED = 'run_command refused: cannot tell which variables are secrets: ~/.jos/secrets/';
|
|
51
|
+
|
|
52
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
53
|
+
const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
54
|
+
const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
55
|
+
|
|
56
|
+
// ------------------------------------------------------------------------------------------- path policy
|
|
57
|
+
|
|
58
|
+
// Case and Unicode form folded, for the denial only (never to widen the root).
|
|
59
|
+
const fold = (p) => p.normalize('NFC').toLowerCase();
|
|
60
|
+
|
|
61
|
+
// p equals base or lies under it. Trailing separators are stripped first so that a root of "/" works.
|
|
62
|
+
function within(p, base) {
|
|
63
|
+
return p === base || p.startsWith(base.replace(/[\\/]+$/, '') + path.sep);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The real path of `abs`; for a path that does not exist yet, the real path of its deepest existing ancestor
|
|
67
|
+
// joined with the missing components. null when the way there is a dangling symlink, a loop, a component that
|
|
68
|
+
// is not a directory or anything unreadable: such a path is denied.
|
|
69
|
+
function canonical(abs) {
|
|
70
|
+
const rest = [];
|
|
71
|
+
let cur = abs;
|
|
72
|
+
for (;;) {
|
|
73
|
+
try {
|
|
74
|
+
return path.join(fs.realpathSync.native(cur), ...rest);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (e.code !== 'ENOENT' || isLink(cur)) return null;
|
|
77
|
+
rest.unshift(path.basename(cur));
|
|
78
|
+
cur = path.dirname(cur);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isLink(p) {
|
|
84
|
+
try { return fs.lstatSync(p).isSymbolicLink(); } catch (e) { return false; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const inodeKey = (st) => st.dev + ':' + st.ino;
|
|
88
|
+
|
|
89
|
+
// The protected locations for one tool call, recomputed every time (a vault created while the server runs is
|
|
90
|
+
// protected from its first byte). Lexical and real forms of ~/.jos and ~/.jos/secrets, folded.
|
|
91
|
+
function guard(ctx) {
|
|
92
|
+
const jos = ctx.home;
|
|
93
|
+
const secrets = path.join(jos, 'secrets');
|
|
94
|
+
const josForms = [jos, canonical(jos)].filter(Boolean).map(fold);
|
|
95
|
+
const secretForms = [secrets, canonical(secrets)].filter(Boolean).map(fold);
|
|
96
|
+
let inodes = null;
|
|
97
|
+
return {
|
|
98
|
+
josForms,
|
|
99
|
+
// A write never lands under ~/.jos (lexical or real form).
|
|
100
|
+
underJos(p) { const f = fold(p); return josForms.some((j) => within(f, j)); },
|
|
101
|
+
isProtected(p) {
|
|
102
|
+
const f = fold(p);
|
|
103
|
+
if (secretForms.some((s) => within(f, s))) return true;
|
|
104
|
+
const base = path.basename(f);
|
|
105
|
+
return (base === '.master' || base === 'vault.json') && josForms.some((j) => within(f, j));
|
|
106
|
+
},
|
|
107
|
+
// dev:ino of ~/.jos/secrets and of every entry in it: a path whose own inode or any ancestor's is one of
|
|
108
|
+
// these is inside the secrets, whatever its name (hard link, case folding, a bind or firmlink).
|
|
109
|
+
inodes() {
|
|
110
|
+
if (inodes) return inodes;
|
|
111
|
+
inodes = new Set();
|
|
112
|
+
const dir = canonical(secrets);
|
|
113
|
+
let names = [];
|
|
114
|
+
try {
|
|
115
|
+
inodes.add(inodeKey(fs.statSync(dir)));
|
|
116
|
+
names = fs.readdirSync(dir);
|
|
117
|
+
} catch (e) { /* no secrets directory: nothing to match */ }
|
|
118
|
+
for (const n of names) {
|
|
119
|
+
try { inodes.add(inodeKey(fs.statSync(path.join(dir, n)))); } catch (e) { /* a dangling entry */ }
|
|
120
|
+
}
|
|
121
|
+
return inodes;
|
|
122
|
+
},
|
|
123
|
+
// dev:ino of ~/.jos itself, for writes.
|
|
124
|
+
josInode() {
|
|
125
|
+
const st = fs.statSync(jos, { throwIfNoEntry: false });
|
|
126
|
+
return st ? inodeKey(st) : null;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// True when `real` or one of its existing ancestors has one of the given dev:ino keys.
|
|
132
|
+
function ancestorHits(real, keys) {
|
|
133
|
+
let cur = real, prev = null;
|
|
134
|
+
while (cur !== prev) {
|
|
135
|
+
const st = fs.statSync(cur, { throwIfNoEntry: false });
|
|
136
|
+
if (st && keys.has(inodeKey(st))) return true;
|
|
137
|
+
prev = cur;
|
|
138
|
+
cur = path.dirname(cur);
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A path given by the client, checked: its real path, inside the root, not protected by name nor by the inode of
|
|
144
|
+
// itself or an ancestor, and for a write not under ~/.jos at all. Otherwise DENIED, whether the file exists or not.
|
|
145
|
+
function checkedPath(ctx, g, input, what, write) {
|
|
146
|
+
if (typeof input !== 'string' || input.includes('\0')) throw new Error('Invalid params: ' + what + ' must be a string');
|
|
147
|
+
const abs = path.resolve(ctx.root, input);
|
|
148
|
+
const real = canonical(abs);
|
|
149
|
+
if (real === null || !within(real, ctx.root) || g.isProtected(abs) || g.isProtected(real)) throw new Error(DENIED);
|
|
150
|
+
const keys = new Set(g.inodes());
|
|
151
|
+
if (write) {
|
|
152
|
+
if (g.underJos(abs) || g.underJos(real)) throw new Error(DENIED);
|
|
153
|
+
keys.add(g.josInode());
|
|
154
|
+
}
|
|
155
|
+
if (ancestorHits(real, keys)) throw new Error(DENIED);
|
|
156
|
+
return real;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A fixed file under ~/.jos (modules.json, runs/...): its real path, or null when it is protected or unresolvable.
|
|
160
|
+
function homePath(ctx, g, ...parts) {
|
|
161
|
+
const real = canonical(path.join(ctx.home, ...parts));
|
|
162
|
+
return real === null || g.isProtected(real) ? null : real;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Opens an already checked real path without following a symlink put there since, and refuses a hard link to a
|
|
166
|
+
// secret. `shown` is the path as the client wrote it (never the real path, never content).
|
|
167
|
+
function openChecked(g, real, write, shown) {
|
|
168
|
+
const flags = (write ? fs.constants.O_WRONLY | fs.constants.O_CREAT : fs.constants.O_RDONLY) | O_NOFOLLOW | O_NONBLOCK;
|
|
169
|
+
let fd;
|
|
170
|
+
try { fd = fs.openSync(real, flags, 0o666); } catch (e) {
|
|
171
|
+
throw new Error(e.code === 'ENOENT' ? 'File not found: ' + shown : 'Cannot open ' + shown + ': ' + e.code);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const st = fs.fstatSync(fd);
|
|
175
|
+
if (g.inodes().has(inodeKey(st))) throw new Error(DENIED);
|
|
176
|
+
return { fd, st };
|
|
177
|
+
} catch (e) {
|
|
178
|
+
fs.closeSync(fd);
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// An entry met while listing or searching: hidden when protected, or when it is a symlink that leaves the root,
|
|
184
|
+
// dangles or ends in a protected place.
|
|
185
|
+
function visible(ctx, g, fullPath, entry) {
|
|
186
|
+
if (g.isProtected(fullPath)) return false;
|
|
187
|
+
if (entry.isSymbolicLink()) {
|
|
188
|
+
const real = canonical(fullPath);
|
|
189
|
+
if (real === null || !within(real, ctx.root) || g.isProtected(real)) return false;
|
|
190
|
+
}
|
|
191
|
+
// A hard link to a secret is hidden too (a missing entry, gone since readdir, is harmless to show).
|
|
192
|
+
const st = fs.statSync(fullPath, { throwIfNoEntry: false });
|
|
193
|
+
return !st || !g.inodes().has(inodeKey(st));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// JSON of a fixed file under ~/.jos, or undefined when missing, protected, unreadable or not JSON.
|
|
197
|
+
function readHomeJson(ctx, g, ...parts) {
|
|
198
|
+
const real = homePath(ctx, g, ...parts);
|
|
199
|
+
if (real === null) return undefined;
|
|
200
|
+
try {
|
|
201
|
+
const { fd } = openChecked(g, real, false, parts.join('/'));
|
|
202
|
+
try { return JSON.parse(fs.readFileSync(fd, 'utf8')); } finally { fs.closeSync(fd); }
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function readModulesConfig(ctx, g) {
|
|
209
|
+
const raw = readHomeJson(ctx, g, 'modules.json');
|
|
210
|
+
return isPlainObject(raw) ? raw : { enabled: [], disabled: [] };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Names of the secrets (vault.json keys and keychain.json names), upper-cased. A file that exists but cannot be
|
|
214
|
+
// read or has another shape refuses the command: better no shell than a shell with a secret in its environment.
|
|
215
|
+
function secretNames(ctx) {
|
|
216
|
+
const dir = path.join(ctx.home, 'secrets');
|
|
217
|
+
const sources = [
|
|
218
|
+
['vault.json', (d) => (isPlainObject(d) ? Object.keys(d) : null)],
|
|
219
|
+
['keychain.json', (d) => (isPlainObject(d) && Array.isArray(d.names) ? d.names.map(String) : null)],
|
|
220
|
+
];
|
|
221
|
+
const names = new Set();
|
|
222
|
+
for (const [file, pick] of sources) {
|
|
223
|
+
let text;
|
|
224
|
+
try { text = fs.readFileSync(path.join(dir, file), 'utf8'); } catch (e) {
|
|
225
|
+
if (e.code === 'ENOENT') continue;
|
|
226
|
+
throw new Error(SHELL_REFUSED + file + ' cannot be read (' + e.code + ')');
|
|
227
|
+
}
|
|
228
|
+
let list = null;
|
|
229
|
+
try { list = pick(JSON.parse(text)); } catch (e) { /* reported below */ }
|
|
230
|
+
if (list === null) throw new Error(SHELL_REFUSED + file + ' does not have the expected shape');
|
|
231
|
+
for (const n of list) names.add(n.toUpperCase());
|
|
232
|
+
}
|
|
233
|
+
return names;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The child's environment: the server's, minus every variable named like a secret (compared without case, so
|
|
237
|
+
// that Windows' case-insensitive names cannot slip through). This also drops what `jos secrets exec` injected.
|
|
238
|
+
function childEnv(ctx) {
|
|
239
|
+
const drop = secretNames(ctx);
|
|
240
|
+
const env = {};
|
|
241
|
+
for (const [k, v] of Object.entries(ctx.env)) if (!drop.has(k.toUpperCase())) env[k] = v;
|
|
242
|
+
return env;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Glob match on one name: `*` any run, `?` one character, everything else literal. Linear backtracking only (no
|
|
246
|
+
// RegExp built from client text, so no catastrophic backtracking).
|
|
247
|
+
function globMatch(pattern, name) {
|
|
248
|
+
let i = 0, j = 0, star = -1, mark = 0;
|
|
249
|
+
while (j < name.length) {
|
|
250
|
+
if (i < pattern.length && (pattern[i] === '?' || pattern[i] === name[j])) { i++; j++; }
|
|
251
|
+
else if (pattern[i] === '*') { star = i++; mark = j; }
|
|
252
|
+
else if (star !== -1) { i = star + 1; j = ++mark; }
|
|
253
|
+
else return false;
|
|
254
|
+
}
|
|
255
|
+
while (pattern[i] === '*') i++;
|
|
256
|
+
return i === pattern.length;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------------------------- tools
|
|
260
|
+
|
|
261
|
+
// Tool definitions for MCP
|
|
262
|
+
const TOOLS = [
|
|
263
|
+
{
|
|
264
|
+
name: 'list_files',
|
|
265
|
+
description: 'List files and directories in a given path (inside the working directory)',
|
|
266
|
+
inputSchema: {
|
|
267
|
+
type: 'object',
|
|
268
|
+
properties: {
|
|
269
|
+
path: { type: 'string', description: 'Directory path to list' },
|
|
270
|
+
recursive: { type: 'boolean', description: 'List recursively (max depth 3)', default: false }
|
|
271
|
+
},
|
|
272
|
+
required: ['path']
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: 'read_file',
|
|
277
|
+
description: 'Read the contents of a file (inside the working directory)',
|
|
278
|
+
inputSchema: {
|
|
279
|
+
type: 'object',
|
|
280
|
+
properties: {
|
|
281
|
+
path: { type: 'string', description: 'File path to read' },
|
|
282
|
+
encoding: { type: 'string', description: 'File encoding', default: 'utf8' }
|
|
283
|
+
},
|
|
284
|
+
required: ['path']
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: 'write_file',
|
|
289
|
+
description: 'Write content to a file (creates or overwrites; inside the working directory, never under ~/.jos; only with jos serve --mcp --allow-write)',
|
|
290
|
+
inputSchema: {
|
|
291
|
+
type: 'object',
|
|
292
|
+
properties: {
|
|
293
|
+
path: { type: 'string', description: 'File path to write' },
|
|
294
|
+
content: { type: 'string', description: 'Content to write' }
|
|
295
|
+
},
|
|
296
|
+
required: ['path', 'content']
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: 'search_files',
|
|
301
|
+
description: 'Search for files whose name matches a glob pattern (inside the working directory)',
|
|
302
|
+
inputSchema: {
|
|
303
|
+
type: 'object',
|
|
304
|
+
properties: {
|
|
305
|
+
directory: { type: 'string', description: 'Directory to search in' },
|
|
306
|
+
pattern: { type: 'string', description: 'Glob on the file name: * any run, ? one character (e.g. "*.jos")' }
|
|
307
|
+
},
|
|
308
|
+
required: ['directory', 'pattern']
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: 'read_markdown',
|
|
313
|
+
description: 'Read and return markdown file content with metadata',
|
|
314
|
+
inputSchema: {
|
|
315
|
+
type: 'object',
|
|
316
|
+
properties: {
|
|
317
|
+
path: { type: 'string', description: 'Markdown file path' }
|
|
318
|
+
},
|
|
319
|
+
required: ['path']
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: 'list_modules',
|
|
324
|
+
description: 'List available JOS modules in ~/.jos/modules',
|
|
325
|
+
inputSchema: {
|
|
326
|
+
type: 'object',
|
|
327
|
+
properties: {}
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: 'hot_reload',
|
|
332
|
+
description: 'Reload JOS module state from disk',
|
|
333
|
+
inputSchema: {
|
|
334
|
+
type: 'object',
|
|
335
|
+
properties: {}
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
name: SHELL_TOOL,
|
|
340
|
+
description: 'Run a program with arguments, without a shell, and return its output (only with jos serve --mcp --allow-shell)',
|
|
341
|
+
inputSchema: {
|
|
342
|
+
type: 'object',
|
|
343
|
+
properties: {
|
|
344
|
+
command: { type: 'string', description: 'Program to run (a name looked up in PATH, or a path); no shell' },
|
|
345
|
+
args: { type: 'array', items: { type: 'string' }, description: 'Arguments, one per item (default: none)', default: [] },
|
|
346
|
+
cwd: { type: 'string', description: 'Working directory (optional, inside the working directory, not "/")' },
|
|
347
|
+
timeout: { type: 'number', description: 'Timeout in ms (default: 30000, max 600000)', default: 30000 }
|
|
348
|
+
},
|
|
349
|
+
required: ['command']
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: 'list_processes',
|
|
354
|
+
description: 'List running JOS shadow clones/processes',
|
|
355
|
+
inputSchema: {
|
|
356
|
+
type: 'object',
|
|
357
|
+
properties: {}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
// Tool handlers
|
|
363
|
+
const toolHandlers = {
|
|
364
|
+
async list_files({ path: dirPath, recursive = false }, ctx) {
|
|
365
|
+
const g = guard(ctx);
|
|
366
|
+
const real = checkedPath(ctx, g, dirPath, 'path');
|
|
367
|
+
let stat;
|
|
368
|
+
try { stat = fs.statSync(real); } catch (e) { throw new Error('Path not found: ' + dirPath); }
|
|
369
|
+
if (!stat.isDirectory()) throw new Error('Not a directory: ' + dirPath);
|
|
370
|
+
|
|
371
|
+
const results = [];
|
|
372
|
+
const scan = (dir, depth) => {
|
|
373
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
374
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
375
|
+
const fullPath = path.join(dir, entry.name);
|
|
376
|
+
if (!visible(ctx, g, fullPath, entry)) continue;
|
|
377
|
+
results.push({
|
|
378
|
+
name: entry.name,
|
|
379
|
+
path: path.relative(ctx.root, fullPath),
|
|
380
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
381
|
+
size: entry.isFile() ? fs.statSync(fullPath).size : undefined
|
|
382
|
+
});
|
|
383
|
+
if (entry.isDirectory() && recursive && depth < 3) scan(fullPath, depth + 1);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
scan(real, 0);
|
|
387
|
+
return { files: results, count: results.length };
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
async read_file({ path: filePath, encoding = 'utf8' }, ctx) {
|
|
391
|
+
if (!Buffer.isEncoding(encoding)) throw new Error('Invalid params: unknown encoding');
|
|
392
|
+
const g = guard(ctx);
|
|
393
|
+
const real = checkedPath(ctx, g, filePath, 'path');
|
|
394
|
+
const { fd, st } = openChecked(g, real, false, filePath);
|
|
395
|
+
try {
|
|
396
|
+
if (st.isDirectory()) throw new Error('Cannot read directory as file: ' + filePath);
|
|
397
|
+
if (!st.isFile()) throw new Error('Not a regular file: ' + filePath);
|
|
398
|
+
if (st.size > MAX_READ) throw new Error('File too large (max 10MB)');
|
|
399
|
+
const content = fs.readFileSync(fd, encoding);
|
|
400
|
+
return { content, path: filePath, size: st.size, modified: st.mtime.toISOString() };
|
|
401
|
+
} finally {
|
|
402
|
+
fs.closeSync(fd);
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
async write_file({ path: filePath, content }, ctx) {
|
|
407
|
+
if (typeof content !== 'string') throw new Error('Invalid params: content must be a string');
|
|
408
|
+
const g = guard(ctx);
|
|
409
|
+
const real = checkedPath(ctx, g, filePath, 'path', true);
|
|
410
|
+
try { fs.mkdirSync(path.dirname(real), { recursive: true }); } catch (e) {
|
|
411
|
+
throw new Error('Cannot write ' + filePath + ': ' + e.code);
|
|
412
|
+
}
|
|
413
|
+
// Opened without O_TRUNC: the inode is checked first, then the file is truncated through the same fd.
|
|
414
|
+
const { fd } = openChecked(g, real, true, filePath);
|
|
415
|
+
try {
|
|
416
|
+
fs.ftruncateSync(fd, 0);
|
|
417
|
+
fs.writeFileSync(fd, content, 'utf8');
|
|
418
|
+
} finally {
|
|
419
|
+
fs.closeSync(fd);
|
|
420
|
+
}
|
|
421
|
+
return { success: true, path: filePath, size: Buffer.byteLength(content, 'utf8') };
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
async search_files({ directory, pattern }, ctx) {
|
|
425
|
+
if (typeof pattern !== 'string' || pattern.length > 1024) throw new Error('Invalid params: pattern must be a string of at most 1024 characters');
|
|
426
|
+
const g = guard(ctx);
|
|
427
|
+
const real = checkedPath(ctx, g, directory, 'directory');
|
|
428
|
+
const glob = pattern.replace(/^(\*\*\/)+/, ''); // names have no "/": "**/*.jos" means "*.jos" at any depth
|
|
429
|
+
const results = [];
|
|
430
|
+
const scan = (dir, depth) => {
|
|
431
|
+
if (depth > 5) return;
|
|
432
|
+
try {
|
|
433
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
434
|
+
if (results.length >= 100) return;
|
|
435
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
436
|
+
const fullPath = path.join(dir, entry.name);
|
|
437
|
+
if (!visible(ctx, g, fullPath, entry)) continue;
|
|
438
|
+
if (globMatch(glob, entry.name)) {
|
|
439
|
+
results.push({
|
|
440
|
+
name: entry.name,
|
|
441
|
+
path: path.relative(ctx.root, fullPath),
|
|
442
|
+
type: entry.isDirectory() ? 'directory' : 'file'
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (entry.isDirectory()) scan(fullPath, depth + 1);
|
|
446
|
+
}
|
|
447
|
+
} catch (e) { /* not a directory, or no permission: nothing found there */ }
|
|
448
|
+
};
|
|
449
|
+
scan(real, 0);
|
|
450
|
+
return { matches: results, count: results.length };
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
async read_markdown({ path: filePath }, ctx) {
|
|
454
|
+
const result = await toolHandlers.read_file({ path: filePath }, ctx);
|
|
455
|
+
return {
|
|
456
|
+
content: result.content,
|
|
457
|
+
path: filePath,
|
|
458
|
+
type: 'markdown',
|
|
459
|
+
lines: result.content.split('\n').length
|
|
460
|
+
};
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
async list_modules(params, ctx) {
|
|
464
|
+
const g = guard(ctx);
|
|
465
|
+
const config = readModulesConfig(ctx, g);
|
|
466
|
+
const enabledList = Array.isArray(config.enabled) ? config.enabled : null; // no list: all enabled
|
|
467
|
+
let entries = [];
|
|
468
|
+
try { entries = fs.readdirSync(homePath(ctx, g, 'modules'), { withFileTypes: true }); } catch (e) { /* no modules */ }
|
|
469
|
+
const modules = [];
|
|
470
|
+
for (const entry of entries) {
|
|
471
|
+
if (!entry.isDirectory()) continue;
|
|
472
|
+
const raw = readHomeJson(ctx, g, 'modules', entry.name, 'package.json');
|
|
473
|
+
const meta = isPlainObject(raw) ? raw : {};
|
|
474
|
+
modules.push({
|
|
475
|
+
name: entry.name,
|
|
476
|
+
version: meta.version || '1.0.0',
|
|
477
|
+
description: meta.description || '',
|
|
478
|
+
enabled: enabledList === null || enabledList.includes(entry.name)
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return { modules, count: modules.length };
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
async hot_reload(params, ctx) {
|
|
485
|
+
const config = readModulesConfig(ctx, guard(ctx));
|
|
486
|
+
return {
|
|
487
|
+
success: true,
|
|
488
|
+
reloaded: true,
|
|
489
|
+
enabled: strings(config.enabled),
|
|
490
|
+
disabled: strings(config.disabled)
|
|
491
|
+
};
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
async run_command({ command, args = [], cwd = '.', timeout = 30000 }, ctx) {
|
|
495
|
+
const arg = (a) => typeof a === 'string' && !a.includes('\0');
|
|
496
|
+
if (!arg(command) || command === '') throw new Error('Invalid params: command must be a non-empty string');
|
|
497
|
+
if (!Array.isArray(args) || !args.every(arg)) throw new Error('Invalid params: args must be an array of strings');
|
|
498
|
+
if (typeof timeout !== 'number' || !(timeout > 0)) throw new Error('Invalid params: timeout must be a positive number of milliseconds');
|
|
499
|
+
const workDir = checkedPath(ctx, guard(ctx), cwd, 'cwd');
|
|
500
|
+
if (path.dirname(workDir) === workDir) throw new Error('Invalid params: cwd must not be the root of the file system');
|
|
501
|
+
const env = childEnv(ctx);
|
|
502
|
+
// Every command is announced where the user can see it (JSON-quoted: no terminal escapes get through).
|
|
503
|
+
ui.printer(ctx.stderr).step('jos serve: run_command ' + JSON.stringify([command, ...args]) + ' in ' + JSON.stringify(workDir));
|
|
504
|
+
const r = spawnSync(command, args, {
|
|
505
|
+
shell: false, // an argv, never a command line: no expansion, no `;`, no `$(...)`
|
|
506
|
+
cwd: workDir,
|
|
507
|
+
env,
|
|
508
|
+
timeout: Math.min(timeout, MAX_TIMEOUT),
|
|
509
|
+
maxBuffer: 1024 * 1024, // 1MB
|
|
510
|
+
encoding: 'utf8',
|
|
511
|
+
stdio: ['ignore', 'pipe', 'pipe'] // never the protocol's stdin
|
|
512
|
+
});
|
|
513
|
+
if (r.status === 0 && !r.error) return { success: true, output: r.stdout, command, args, cwd: workDir };
|
|
514
|
+
return {
|
|
515
|
+
success: false,
|
|
516
|
+
error: r.error ? r.error.message : (r.signal ? 'Command killed by ' + r.signal : 'Command failed with exit code ' + r.status),
|
|
517
|
+
output: r.stdout || '',
|
|
518
|
+
stderr: r.stderr || '',
|
|
519
|
+
command,
|
|
520
|
+
args
|
|
521
|
+
};
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
async list_processes(params, ctx) {
|
|
525
|
+
const g = guard(ctx);
|
|
526
|
+
let runs = [];
|
|
527
|
+
try { runs = fs.readdirSync(homePath(ctx, g, 'runs')).reverse().slice(0, 20); } catch (e) { /* no runs */ }
|
|
528
|
+
const processes = [];
|
|
529
|
+
for (const runId of runs) {
|
|
530
|
+
const report = readHomeJson(ctx, g, 'runs', runId, 'report.json');
|
|
531
|
+
if (!isPlainObject(report)) continue;
|
|
532
|
+
const meta = isPlainObject(report.meta) ? report.meta : {};
|
|
533
|
+
let alive = false;
|
|
534
|
+
if (meta.pid) {
|
|
535
|
+
try { process.kill(meta.pid, 0); alive = true; } catch (e) { /* not running */ }
|
|
536
|
+
}
|
|
537
|
+
processes.push({
|
|
538
|
+
epochId: runId,
|
|
539
|
+
pid: meta.pid,
|
|
540
|
+
command: report.command,
|
|
541
|
+
status: alive ? 'running' : 'stopped',
|
|
542
|
+
port: report.port
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
return { processes, count: processes.length };
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// ------------------------------------------------------------------------------------------- protocol
|
|
550
|
+
|
|
551
|
+
// The context of one server: ~/.jos, the real working directory, whether run_command exists, the environment
|
|
552
|
+
// a child would start from and where notices go.
|
|
553
|
+
function createContext(home, opts) {
|
|
554
|
+
const o = opts || {};
|
|
555
|
+
return {
|
|
556
|
+
home: path.resolve(home),
|
|
557
|
+
root: fs.realpathSync.native(o.root || process.cwd()),
|
|
558
|
+
allowShell: o.allowShell === true,
|
|
559
|
+
allowWrite: o.allowWrite === true,
|
|
560
|
+
env: o.env || process.env,
|
|
561
|
+
stderr: o.stderr || process.stderr,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// A gated tool exists (listed and callable) only when its switch is on.
|
|
566
|
+
const enabled = (context, name) => !hasOwn(GATED, name) || context[GATED[name]] === true;
|
|
567
|
+
const toolsFor = (context) => TOOLS.filter((t) => enabled(context, t.name));
|
|
568
|
+
|
|
569
|
+
// JSON-RPC 2.0 message handler. Only a request object with jsonrpc "2.0" and a string method is dispatched;
|
|
570
|
+
// anything else is -32600 with id null (JSON-RPC 2.0 §5.1). A request without id is a notification: no response.
|
|
571
|
+
async function handleMessage(message, context) {
|
|
572
|
+
if (!isPlainObject(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
|
573
|
+
const id = isPlainObject(message) && hasOwn(message, 'id') && (typeof message.id === 'string' || typeof message.id === 'number' || message.id === null) ? message.id : null;
|
|
574
|
+
return { jsonrpc: '2.0', id, error: { code: -32600, message: 'Invalid Request: not a JSON-RPC 2.0 request' } };
|
|
575
|
+
}
|
|
576
|
+
const { id, method, params } = message;
|
|
577
|
+
const notification = !hasOwn(message, 'id');
|
|
578
|
+
if (params !== undefined && !isPlainObject(params) && !Array.isArray(params)) {
|
|
579
|
+
return notification ? null : { jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params: expected an object' } };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
switch (method) {
|
|
584
|
+
case 'initialize':
|
|
585
|
+
return {
|
|
586
|
+
jsonrpc: '2.0',
|
|
587
|
+
id,
|
|
588
|
+
result: {
|
|
589
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
590
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
591
|
+
capabilities: {
|
|
592
|
+
tools: { listChanged: false }
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
case 'initialized':
|
|
598
|
+
case 'notifications/initialized':
|
|
599
|
+
// Notification, no response needed
|
|
600
|
+
return null;
|
|
601
|
+
|
|
602
|
+
case 'tools/list':
|
|
603
|
+
return {
|
|
604
|
+
jsonrpc: '2.0',
|
|
605
|
+
id,
|
|
606
|
+
result: { tools: toolsFor(context) }
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
case 'tools/call': {
|
|
610
|
+
const p = isPlainObject(params) ? params : {};
|
|
611
|
+
const name = typeof p.name === 'string' ? p.name : null;
|
|
612
|
+
// Own properties only: 'constructor', 'toString' or '__proto__' are not tools; run_command exists
|
|
613
|
+
// only with --allow-shell and write_file only with --allow-write.
|
|
614
|
+
const known = name !== null && hasOwn(toolHandlers, name) && enabled(context, name);
|
|
615
|
+
|
|
616
|
+
if (!known) {
|
|
617
|
+
return {
|
|
618
|
+
jsonrpc: '2.0',
|
|
619
|
+
id,
|
|
620
|
+
error: { code: -32601, message: `Unknown tool: ${name === null ? '(missing name)' : name}` }
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
if (p.arguments !== undefined && !isPlainObject(p.arguments)) {
|
|
624
|
+
return { jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params: arguments must be an object' } };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const result = await toolHandlers[name](p.arguments || {}, context);
|
|
628
|
+
return {
|
|
629
|
+
jsonrpc: '2.0',
|
|
630
|
+
id,
|
|
631
|
+
result: {
|
|
632
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
case 'ping':
|
|
638
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
639
|
+
|
|
640
|
+
default:
|
|
641
|
+
return {
|
|
642
|
+
jsonrpc: '2.0',
|
|
643
|
+
id,
|
|
644
|
+
error: { code: -32601, message: `Method not found: ${method}` }
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
} catch (err) {
|
|
648
|
+
// Handlers throw Error objects whose messages never carry file content.
|
|
649
|
+
return { jsonrpc: '2.0', id, error: { code: -32000, message: err.message } };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Start MCP server on stdio. opts: { allowShell, allowWrite, root, explicitRoot, env, stdin, stdout, stderr, exit };
|
|
654
|
+
// streams and exit default to the process (tests pass their own). Resolves to an exit code when it refuses to start.
|
|
655
|
+
async function startMCPServer(home, o = {}) {
|
|
656
|
+
const err = ui.printer(o.stderr || process.stderr);
|
|
657
|
+
let context;
|
|
658
|
+
try {
|
|
659
|
+
context = createContext(home, o);
|
|
660
|
+
if (!fs.statSync(context.root).isDirectory()) throw new Error('not a directory');
|
|
661
|
+
} catch (e) {
|
|
662
|
+
err.fail('jos serve: --root ' + JSON.stringify(o.root) + ' is not an existing directory');
|
|
663
|
+
return 64;
|
|
664
|
+
}
|
|
665
|
+
// The home directory is the parent of ~/.jos. A root that is it, or contains it ("/" included), exposes every
|
|
666
|
+
// file of the user: refused unless the user named it with --root.
|
|
667
|
+
const userHome = canonical(path.dirname(context.home));
|
|
668
|
+
if (!o.explicitRoot && userHome !== null && within(fold(userHome), fold(context.root))) {
|
|
669
|
+
err.fail('jos serve: refusing to serve ' + JSON.stringify(context.root) + ': it is "/", your home directory or contains it.');
|
|
670
|
+
err.hint('Start it in a project directory, or name the root explicitly with --root <dir>.');
|
|
671
|
+
return 2;
|
|
672
|
+
}
|
|
673
|
+
const input = o.stdin || process.stdin;
|
|
674
|
+
const output = o.stdout || process.stdout;
|
|
675
|
+
const exit = o.exit || process.exit;
|
|
676
|
+
|
|
677
|
+
// A person at a terminal gets a notice on stderr (never on stdout, the JSON-RPC channel); a client never does.
|
|
678
|
+
if (input.isTTY) guide.notice(err, context, toolsFor(context).map((t) => t.name));
|
|
679
|
+
if (guard(context).josForms.some((j) => within(j, fold(context.root)))) {
|
|
680
|
+
err.warn('jos serve: the root contains ~/.jos: ~/.jos/secrets, .master and vault.json stay denied, ' +
|
|
681
|
+
'every other file under it is readable by the MCP client (never writable)');
|
|
682
|
+
}
|
|
683
|
+
if (context.allowWrite) {
|
|
684
|
+
err.warn('jos serve: --allow-write: write_file is on; the MCP client can create and overwrite files in ' +
|
|
685
|
+
JSON.stringify(context.root) + ' (never under ~/.jos).');
|
|
686
|
+
}
|
|
687
|
+
if (context.allowShell) {
|
|
688
|
+
err.warn('jos serve: --allow-shell: run_command is on; the MCP client can run any program as you, ' +
|
|
689
|
+
'including one that reads ~/.jos/secrets. Every command is shown here. Do not give this to an untrusted agent ' +
|
|
690
|
+
'without an external sandbox.');
|
|
691
|
+
}
|
|
692
|
+
if (input.isTTY) { err.hint('Ctrl-C to exit.'); err.line(); }
|
|
693
|
+
|
|
694
|
+
// Use readline for line-based JSON-RPC
|
|
695
|
+
const rl = readline.createInterface({ input, terminal: false });
|
|
696
|
+
|
|
697
|
+
// Send response to stdout
|
|
698
|
+
const send = (response) => {
|
|
699
|
+
if (response) {
|
|
700
|
+
output.write(JSON.stringify(response) + '\n'); // the JSON-RPC channel (STYLE.md, exceptions)
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
// Messages are processed one at a time, in input order, so responses come out in input order too
|
|
705
|
+
// (a parse error must not overtake the answer to an earlier line: both would carry id null).
|
|
706
|
+
const processLine = async (line) => {
|
|
707
|
+
if (!line.trim()) return;
|
|
708
|
+
let message;
|
|
709
|
+
try { message = JSON.parse(line); } catch (err) {
|
|
710
|
+
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: ' + err.message } });
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const response = await handleMessage(message, context);
|
|
714
|
+
// A well-formed request without id is a notification: no response, not even an error (JSON-RPC 2.0 §4.1).
|
|
715
|
+
if (isPlainObject(message) && message.jsonrpc === '2.0' && typeof message.method === 'string' && !hasOwn(message, 'id')) return;
|
|
716
|
+
send(response);
|
|
717
|
+
};
|
|
718
|
+
let queue = Promise.resolve();
|
|
719
|
+
rl.on('line', (line) => {
|
|
720
|
+
queue = queue.then(() => processLine(line)).catch((err) => {
|
|
721
|
+
send({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Internal error: ' + err.message } });
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
rl.on('close', () => {
|
|
726
|
+
// Answer everything already read before leaving.
|
|
727
|
+
queue.then(() => exit(0));
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// Keep process alive
|
|
731
|
+
input.resume();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
module.exports = { startMCPServer, handleMessage, createContext, TOOLS, toolHandlers, globMatch };
|