claude-simple 0.2.9 → 0.2.11
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/README.md +13 -5
- package/dist/app.0.2.9.min.js +143 -139
- package/dist/style.0.2.9.min.css +1 -1
- package/package.json +11 -3
- package/public/chat.js +5 -5
- package/public/files.js +5 -5
- package/public/index.html +6 -7
- package/public/lib.js +1 -1
- package/public/router.js +36 -8
- package/public/settings.js +54 -2
- package/public/shell/boot.js +7 -7
- package/public/shell/layout.js +2 -3
- package/public/shell/session-list.js +5 -12
- package/public/shell/templates.js +34 -28
- package/public/shell/util.js +3 -1
- package/public/shell.js +54 -13
- package/public/state.js +36 -5
- package/public/style.css +13 -0
- package/public/sw.js +6 -2
- package/public/ui.min.css +2 -0
- package/public/vendor/highlight-dark.min.css +10 -0
- package/public/vendor/highlight.min.js +1262 -0
- package/public/vendor/jszip.min.js +13 -0
- package/public/vendor/mammoth.browser.min.js +21 -0
- package/public/vendor/marked.min.js +6 -0
- package/public/vendor/mini-react.min.js +4 -0
- package/public/vendor/pdf.min.js +22 -0
- package/public/vendor/pdf.worker.min.js +22 -0
- package/public/vendor/xlsx.full.min.js +22 -0
- package/src/agents/cursor.js +189 -0
- package/src/agents/gemini.js +182 -0
- package/src/agents/meta-agent.js +9 -5
- package/src/agents/models.js +112 -2
- package/src/agents/sessions.js +303 -14
- package/src/app.js +119 -7
- package/src/mcp.js +275 -0
package/src/app.js
CHANGED
|
@@ -14,6 +14,8 @@ import { fileURLToPath } from 'url';
|
|
|
14
14
|
import { runClaude } from './agents/claude.js';
|
|
15
15
|
import { runCodex } from './agents/codex.js';
|
|
16
16
|
import { runGrok } from './agents/grok.js';
|
|
17
|
+
import { runGemini } from './agents/gemini.js';
|
|
18
|
+
import { runCursor } from './agents/cursor.js';
|
|
17
19
|
import {
|
|
18
20
|
scanAllSessions,
|
|
19
21
|
loadSessionMessages,
|
|
@@ -32,6 +34,7 @@ import { createTextBatcher } from './agents/stream-batch.js';
|
|
|
32
34
|
import { normalizeCommand, mapAgentEmit } from './runtime/protocol.js';
|
|
33
35
|
import { createOutbox } from './runtime/outbox.js';
|
|
34
36
|
import { resolveApproval, cancelApprovalsForSession } from './runtime/approvals.js';
|
|
37
|
+
import { createMcpHttpHandler, createMcpService } from './mcp.js';
|
|
35
38
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
39
|
const rootDir = path.join(__dirname, '..');
|
|
37
40
|
|
|
@@ -44,12 +47,16 @@ const SESSION_META_FILE = process.env.SESSION_META_FILE || path.join(rootDir,
|
|
|
44
47
|
const CLAUDE_CLI_PATH = process.env.CLAUDE_CLI_PATH || 'claude';
|
|
45
48
|
const CODEX_CLI_PATH = process.env.CODEX_CLI_PATH || 'codex';
|
|
46
49
|
const GROK_CLI_PATH = process.env.GROK_CLI_PATH || 'grok';
|
|
50
|
+
const GEMINI_CLI_PATH = process.env.GEMINI_CLI_PATH || process.env.AGY_PATH || 'agy';
|
|
51
|
+
const CURSOR_AGENT_PATH = process.env.CURSOR_AGENT_PATH || 'cursor-agent';
|
|
47
52
|
const CODEX_HOME = (process.env.CODEX_HOME || path.join(os.homedir(), '.codex')).replace(/^~/, os.homedir());
|
|
48
53
|
const GROK_HOME = (process.env.GROK_HOME || path.join(os.homedir(), '.grok')).replace(/^~/, os.homedir());
|
|
54
|
+
const GEMINI_HOME = (process.env.GEMINI_HOME || path.join(os.homedir(), '.gemini')).replace(/^~/, os.homedir());
|
|
55
|
+
const CURSOR_HOME = (process.env.CURSOR_HOME || path.join(os.homedir(), '.cursor')).replace(/^~/, os.homedir());
|
|
49
56
|
const ENABLED_AGENTS = (() => {
|
|
50
|
-
const raw = (process.env.AGENTS || 'claude,codex,grok')
|
|
57
|
+
const raw = (process.env.AGENTS || 'claude,codex,grok,gemini,cursor')
|
|
51
58
|
.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
52
|
-
return [...new Set(raw.length ? raw : ['claude', 'codex', 'grok'])];
|
|
59
|
+
return [...new Set(raw.length ? raw : ['claude', 'codex', 'grok', 'gemini', 'cursor'])];
|
|
53
60
|
})();
|
|
54
61
|
const CLAUDE_CONFIG_DIRS = (() => {
|
|
55
62
|
const dirs = (process.env.CLAUDE_CONFIG_DIRS || '')
|
|
@@ -186,6 +193,14 @@ function makeToken(user) {
|
|
|
186
193
|
return jwt.sign({ userId: user.id, username: user.username }, jwtSecret(), { expiresIn: '7d' });
|
|
187
194
|
}
|
|
188
195
|
|
|
196
|
+
function makeMcpToken(user, expiresInSeconds) {
|
|
197
|
+
return jwt.sign(
|
|
198
|
+
{ userId: user.userId ?? user.id, username: user.username, scope: 'mcp', tokenId: crypto.randomUUID() },
|
|
199
|
+
jwtSecret(),
|
|
200
|
+
{ expiresIn: expiresInSeconds },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
189
204
|
function verifyToken(token) {
|
|
190
205
|
try { return jwt.verify(token, jwtSecret()); }
|
|
191
206
|
catch { return null; }
|
|
@@ -306,11 +321,37 @@ async function listSessions(filterCwd = null, agentFilter = null) {
|
|
|
306
321
|
claudeConfigDirs: CLAUDE_CONFIG_DIRS,
|
|
307
322
|
codexHome: CODEX_HOME,
|
|
308
323
|
grokHome: GROK_HOME,
|
|
324
|
+
geminiHome: GEMINI_HOME,
|
|
325
|
+
cursorHome: CURSOR_HOME,
|
|
309
326
|
filterCwd,
|
|
310
327
|
agents,
|
|
311
328
|
});
|
|
312
329
|
}
|
|
313
330
|
|
|
331
|
+
// The HTTP UI endpoint and MCP both need the same agent-aware lookup rule:
|
|
332
|
+
// prefer the stated agent, then fall back only when no preference was given.
|
|
333
|
+
async function readSessionBundle(sessionId, preferredAgent = null) {
|
|
334
|
+
const opts = {
|
|
335
|
+
claudeConfigDirs: CLAUDE_CONFIG_DIRS,
|
|
336
|
+
codexHome: CODEX_HOME,
|
|
337
|
+
grokHome: GROK_HOME,
|
|
338
|
+
geminiHome: GEMINI_HOME,
|
|
339
|
+
cursorHome: CURSOR_HOME,
|
|
340
|
+
};
|
|
341
|
+
const order = preferredAgent
|
|
342
|
+
? [preferredAgent]
|
|
343
|
+
: [...ENABLED_AGENTS];
|
|
344
|
+
for (const agent of order) {
|
|
345
|
+
const bundle = await loadSessionMessages(sessionId, agent, opts);
|
|
346
|
+
if (bundle) {
|
|
347
|
+
return Array.isArray(bundle)
|
|
348
|
+
? { messages: bundle, context: null, agent }
|
|
349
|
+
: { ...bundle, agent: bundle.agent || agent, messages: bundle.messages || [] };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
314
355
|
/**
|
|
315
356
|
* Wrap agent emit → map/filter → outbox (delta coalesce + batch frames).
|
|
316
357
|
* Agents may still call send() with UI-shaped events; unknown noise is dropped.
|
|
@@ -337,6 +378,10 @@ async function dispatchAgent(agent, command, options, send) {
|
|
|
337
378
|
await runCodex(command, options, send, { ...ctx, cliPath: CODEX_CLI_PATH });
|
|
338
379
|
} else if (a === 'grok') {
|
|
339
380
|
await runGrok(command, options, send, { ...ctx, cliPath: GROK_CLI_PATH });
|
|
381
|
+
} else if (a === 'gemini') {
|
|
382
|
+
await runGemini(command, options, send, { ...ctx, cliPath: GEMINI_CLI_PATH, geminiHome: GEMINI_HOME });
|
|
383
|
+
} else if (a === 'cursor') {
|
|
384
|
+
await runCursor(command, options, send, { ...ctx, cliPath: CURSOR_AGENT_PATH, cursorHome: CURSOR_HOME });
|
|
340
385
|
} else {
|
|
341
386
|
await runClaude(command, options, send, { ...ctx, cliPath: CLAUDE_CLI_PATH });
|
|
342
387
|
}
|
|
@@ -496,6 +541,55 @@ export function createApp() {
|
|
|
496
541
|
|
|
497
542
|
app.use(express.json({ limit: '10mb' }));
|
|
498
543
|
|
|
544
|
+
// MCP uses a separate short-lived bearer token. Normal UI JWTs deliberately
|
|
545
|
+
// cannot call /mcp, so a browser token leaked through a URL cannot become an
|
|
546
|
+
// agent-control credential.
|
|
547
|
+
const mcpService = createMcpService({
|
|
548
|
+
listSessions,
|
|
549
|
+
readSession: readSessionBundle,
|
|
550
|
+
dispatchAgent,
|
|
551
|
+
abortSession,
|
|
552
|
+
isSessionBusy,
|
|
553
|
+
enabledAgents: ENABLED_AGENTS,
|
|
554
|
+
toClientSession,
|
|
555
|
+
});
|
|
556
|
+
const authenticateMcp = (req) => {
|
|
557
|
+
const header = req.headers.authorization || '';
|
|
558
|
+
const token = /^Bearer\s+(.+)$/i.exec(header)?.[1];
|
|
559
|
+
if (!token) return false;
|
|
560
|
+
const claims = verifyToken(token);
|
|
561
|
+
if (claims?.scope === 'mcp') return true;
|
|
562
|
+
|
|
563
|
+
// Optional headless deployment token. It is accepted only with an explicit
|
|
564
|
+
// expiry, keeping the same expiration guarantee as issued JWTs.
|
|
565
|
+
const configured = process.env.MCP_TOKEN;
|
|
566
|
+
const expiresRaw = process.env.MCP_TOKEN_EXPIRES_AT;
|
|
567
|
+
if (!configured || !expiresRaw || token.length !== configured.length) return false;
|
|
568
|
+
let expiresAt = Number(expiresRaw);
|
|
569
|
+
if (!Number.isFinite(expiresAt)) expiresAt = Date.parse(expiresRaw);
|
|
570
|
+
if (expiresAt > 0 && expiresAt < 10_000_000_000) expiresAt *= 1000;
|
|
571
|
+
return Number.isFinite(expiresAt) && Date.now() < expiresAt
|
|
572
|
+
&& crypto.timingSafeEqual(Buffer.from(token), Buffer.from(configured));
|
|
573
|
+
};
|
|
574
|
+
app.post('/mcp', createMcpHttpHandler({ service: mcpService, authenticate: authenticateMcp }));
|
|
575
|
+
|
|
576
|
+
// Issue a scoped credential from an already-authenticated UI/API client.
|
|
577
|
+
// The raw secret is returned once and never written to the credential store.
|
|
578
|
+
app.post('/api/mcp/tokens', authMiddleware, (req, res) => {
|
|
579
|
+
try {
|
|
580
|
+
const requested = req.body?.expiresInSeconds ?? 86_400;
|
|
581
|
+
if (!Number.isInteger(requested) || requested < 60 || requested > 2_592_000) {
|
|
582
|
+
return res.status(400).json({ error: 'expiresInSeconds must be an integer between 60 and 2592000' });
|
|
583
|
+
}
|
|
584
|
+
const token = makeMcpToken(req.user, requested);
|
|
585
|
+
const decoded = jwt.decode(token);
|
|
586
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
587
|
+
res.status(201).json({ token, tokenType: 'Bearer', expiresAt: Number(decoded?.exp || 0) * 1000, expiresInSeconds: requested });
|
|
588
|
+
} catch (e) {
|
|
589
|
+
res.status(500).json({ error: e.message });
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
499
593
|
const distDir = path.join(rootDir, 'dist');
|
|
500
594
|
const publicDir = path.join(rootDir, 'public');
|
|
501
595
|
// Only ship the built bundle in production. Local `node server.js` serves
|
|
@@ -716,6 +810,10 @@ export function createApp() {
|
|
|
716
810
|
claudeConfigDirs: CLAUDE_CONFIG_DIRS,
|
|
717
811
|
codexHome: CODEX_HOME,
|
|
718
812
|
grokHome: GROK_HOME,
|
|
813
|
+
geminiHome: GEMINI_HOME,
|
|
814
|
+
geminiCliPath: GEMINI_CLI_PATH,
|
|
815
|
+
cursorHome: CURSOR_HOME,
|
|
816
|
+
cursorAgentPath: CURSOR_AGENT_PATH,
|
|
719
817
|
agents: ENABLED_AGENTS,
|
|
720
818
|
});
|
|
721
819
|
res.json({
|
|
@@ -725,11 +823,15 @@ export function createApp() {
|
|
|
725
823
|
claude: CLAUDE_CONFIG_DIRS,
|
|
726
824
|
codex: CODEX_HOME,
|
|
727
825
|
grok: GROK_HOME,
|
|
826
|
+
gemini: GEMINI_HOME,
|
|
827
|
+
cursor: CURSOR_HOME,
|
|
728
828
|
},
|
|
729
829
|
paths: {
|
|
730
830
|
claude: CLAUDE_CLI_PATH,
|
|
731
831
|
codex: CODEX_CLI_PATH,
|
|
732
832
|
grok: GROK_CLI_PATH,
|
|
833
|
+
gemini: GEMINI_CLI_PATH,
|
|
834
|
+
cursor: CURSOR_AGENT_PATH,
|
|
733
835
|
},
|
|
734
836
|
});
|
|
735
837
|
} catch (e) {
|
|
@@ -833,8 +935,10 @@ export function createApp() {
|
|
|
833
935
|
claudeConfigDirs: CLAUDE_CONFIG_DIRS,
|
|
834
936
|
codexHome: CODEX_HOME,
|
|
835
937
|
grokHome: GROK_HOME,
|
|
938
|
+
geminiHome: GEMINI_HOME,
|
|
939
|
+
cursorHome: CURSOR_HOME,
|
|
836
940
|
};
|
|
837
|
-
const order = [...new Set([preferred, ...ENABLED_AGENTS, 'claude', 'codex', 'grok'].filter(Boolean))];
|
|
941
|
+
const order = [...new Set([preferred, ...ENABLED_AGENTS, 'claude', 'codex', 'grok', 'gemini', 'cursor'].filter(Boolean))];
|
|
838
942
|
let data = null;
|
|
839
943
|
for (const a of order) {
|
|
840
944
|
data = await loadSessionUsage(sessionId, a, opts);
|
|
@@ -925,6 +1029,8 @@ export function createApp() {
|
|
|
925
1029
|
claudeConfigDirs: CLAUDE_CONFIG_DIRS,
|
|
926
1030
|
codexHome: CODEX_HOME,
|
|
927
1031
|
grokHome: GROK_HOME,
|
|
1032
|
+
geminiHome: GEMINI_HOME,
|
|
1033
|
+
cursorHome: CURSOR_HOME,
|
|
928
1034
|
};
|
|
929
1035
|
// Prefer stated agent; only fall back if not found (avoids scanning all agents every time)
|
|
930
1036
|
const order = preferred
|
|
@@ -975,11 +1081,13 @@ export function createApp() {
|
|
|
975
1081
|
claudeConfigDirs: CLAUDE_CONFIG_DIRS,
|
|
976
1082
|
codexHome: CODEX_HOME,
|
|
977
1083
|
grokHome: GROK_HOME,
|
|
1084
|
+
geminiHome: GEMINI_HOME,
|
|
1085
|
+
cursorHome: CURSOR_HOME,
|
|
978
1086
|
};
|
|
979
1087
|
|
|
980
1088
|
let detectedSourceAgent = sourceAgent;
|
|
981
1089
|
if (!detectedSourceAgent) {
|
|
982
|
-
const order = [...new Set([...ENABLED_AGENTS, 'claude', 'codex', 'grok'])];
|
|
1090
|
+
const order = [...new Set([...ENABLED_AGENTS, 'claude', 'codex', 'grok', 'gemini', 'cursor'])];
|
|
983
1091
|
for (const a of order) {
|
|
984
1092
|
const bundle = await loadSessionMessages(sessionId, a, opts);
|
|
985
1093
|
if (bundle) {
|
|
@@ -1009,12 +1117,14 @@ export function createApp() {
|
|
|
1009
1117
|
claudeConfigDirs: CLAUDE_CONFIG_DIRS,
|
|
1010
1118
|
codexHome: CODEX_HOME,
|
|
1011
1119
|
grokHome: GROK_HOME,
|
|
1120
|
+
geminiHome: GEMINI_HOME,
|
|
1121
|
+
cursorHome: CURSOR_HOME,
|
|
1012
1122
|
};
|
|
1013
1123
|
// A selected session already knows its agent. Do not fall back to another
|
|
1014
1124
|
// agent's account-level memory (especially Codex) when that session has none.
|
|
1015
1125
|
const order = preferred
|
|
1016
1126
|
? [preferred]
|
|
1017
|
-
: [...new Set([...ENABLED_AGENTS, 'claude', 'codex', 'grok'])];
|
|
1127
|
+
: [...new Set([...ENABLED_AGENTS, 'claude', 'codex', 'grok', 'gemini', 'cursor'])];
|
|
1018
1128
|
let memory = null;
|
|
1019
1129
|
for (const a of order) {
|
|
1020
1130
|
memory = await loadSessionMemory(sessionId, a, opts);
|
|
@@ -1033,7 +1143,7 @@ export function createApp() {
|
|
|
1033
1143
|
const file = typeof req.query.file === 'string' ? req.query.file : '';
|
|
1034
1144
|
if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return res.status(400).json({ error: 'Invalid session id' });
|
|
1035
1145
|
const row = await readSessionMemoryFile(sessionId, agent, file, {
|
|
1036
|
-
claudeConfigDirs: CLAUDE_CONFIG_DIRS, codexHome: CODEX_HOME, grokHome: GROK_HOME,
|
|
1146
|
+
claudeConfigDirs: CLAUDE_CONFIG_DIRS, codexHome: CODEX_HOME, grokHome: GROK_HOME, geminiHome: GEMINI_HOME, cursorHome: CURSOR_HOME,
|
|
1037
1147
|
});
|
|
1038
1148
|
if (!row) return res.status(404).json({ error: 'Memory file not found' });
|
|
1039
1149
|
res.setHeader('Cache-Control', 'private, max-age=30');
|
|
@@ -1076,6 +1186,8 @@ export function createApp() {
|
|
|
1076
1186
|
claudeConfigDirs: () => CLAUDE_CONFIG_DIRS,
|
|
1077
1187
|
codexHome: () => CODEX_HOME,
|
|
1078
1188
|
grokHome: () => GROK_HOME,
|
|
1189
|
+
geminiHome: () => GEMINI_HOME,
|
|
1190
|
+
cursorHome: () => CURSOR_HOME,
|
|
1079
1191
|
});
|
|
1080
1192
|
|
|
1081
1193
|
/** Write SSE event */
|
|
@@ -1666,7 +1778,7 @@ export async function init() {
|
|
|
1666
1778
|
await bootstrapEnvUser();
|
|
1667
1779
|
await syncConfigDirs();
|
|
1668
1780
|
console.log(`[agents] enabled: ${ENABLED_AGENTS.join(', ')}`);
|
|
1669
|
-
console.log(`[agents] claude=${CLAUDE_CLI_PATH} codex=${CODEX_CLI_PATH} grok=${GROK_CLI_PATH}`);
|
|
1781
|
+
console.log(`[agents] claude=${CLAUDE_CLI_PATH} codex=${CODEX_CLI_PATH} grok=${GROK_CLI_PATH} gemini=${GEMINI_CLI_PATH}`);
|
|
1670
1782
|
}
|
|
1671
1783
|
|
|
1672
1784
|
// (session helpers live in ./agents/sessions.js)
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
// A deliberately small MCP implementation. The Streamable HTTP transport is
|
|
4
|
+
// JSON-RPC over POST; none of the tools need a long-lived server-side session.
|
|
5
|
+
export const MCP_PROTOCOL_VERSION = '2025-03-26';
|
|
6
|
+
|
|
7
|
+
const MAX_EVENTS = 100;
|
|
8
|
+
const MAX_OUTPUT_CHARS = 12_000;
|
|
9
|
+
const MAX_HISTORY_MESSAGES = 2_000;
|
|
10
|
+
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
|
|
12
|
+
function digest(value) {
|
|
13
|
+
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('base64url');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function encodeSessionCursor({ sessionId, agent, count, prefix }) {
|
|
17
|
+
return Buffer.from(JSON.stringify({ v: 1, sessionId, agent, count, prefix })).toString('base64url');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function decodeSessionCursor(cursor) {
|
|
21
|
+
if (typeof cursor !== 'string' || cursor.length > 2_000) return null;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
24
|
+
if (parsed?.v !== 1 || typeof parsed.sessionId !== 'string' || typeof parsed.agent !== 'string'
|
|
25
|
+
|| !Number.isInteger(parsed.count) || parsed.count < 0 || typeof parsed.prefix !== 'string') return null;
|
|
26
|
+
return parsed;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function jsonText(value) {
|
|
33
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toolError(message) {
|
|
37
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stringArg(args, key, { required = false, max = 0 } = {}) {
|
|
41
|
+
const value = args?.[key];
|
|
42
|
+
if (value == null || value === '') {
|
|
43
|
+
if (required) throw new Error(`${key} is required`);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
if (typeof value !== 'string') throw new Error(`${key} must be a string`);
|
|
47
|
+
const result = value.trim();
|
|
48
|
+
if (required && !result) throw new Error(`${key} is required`);
|
|
49
|
+
if (max && result.length > max) throw new Error(`${key} is too long (max ${max})`);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function boundedInt(value, fallback, min, max) {
|
|
54
|
+
if (value == null) return fallback;
|
|
55
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
56
|
+
throw new Error(`must be an integer between ${min} and ${max}`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sessionIdArg(args) {
|
|
62
|
+
const sessionId = stringArg(args, 'sessionId', { required: true, max: 200 });
|
|
63
|
+
if (!SESSION_ID_RE.test(sessionId)) throw new Error('sessionId must be a UUID');
|
|
64
|
+
return sessionId;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function conciseEvent(event) {
|
|
68
|
+
const out = {
|
|
69
|
+
type: event?.type || 'unknown',
|
|
70
|
+
agent: event?.agent,
|
|
71
|
+
sessionId: event?.sessionId,
|
|
72
|
+
};
|
|
73
|
+
if (typeof event?.message === 'string') out.message = event.message.slice(0, 2_000);
|
|
74
|
+
if (typeof event?.result === 'string') out.result = event.result.slice(0, 4_000);
|
|
75
|
+
if (typeof event?.data === 'string') out.data = event.data.slice(0, 4_000);
|
|
76
|
+
if (event?.exitCode != null) out.exitCode = event.exitCode;
|
|
77
|
+
if (event?.aborted) out.aborted = true;
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const MCP_TOOLS = [
|
|
82
|
+
{
|
|
83
|
+
name: 'session_list',
|
|
84
|
+
description: 'List saved Claude, Codex, Grok, Gemini, and Cursor sessions. Use updatedSince for inexpensive polling.',
|
|
85
|
+
inputSchema: {
|
|
86
|
+
type: 'object', properties: {
|
|
87
|
+
agent: { type: 'string', enum: ['claude', 'codex', 'grok', 'gemini', 'cursor'] },
|
|
88
|
+
cwd: { type: 'string' }, updatedSince: { type: 'number' }, limit: { type: 'integer', minimum: 1, maximum: 800 },
|
|
89
|
+
}, additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'session_get',
|
|
94
|
+
description: 'Read a session history. Returns a cursor that can be supplied to session_changes for append-only incremental updates.',
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: 'object', required: ['sessionId'], properties: {
|
|
97
|
+
sessionId: { type: 'string' }, agent: { type: 'string' }, tail: { type: 'integer', minimum: 0, maximum: 50000 },
|
|
98
|
+
}, additionalProperties: false,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'session_changes',
|
|
103
|
+
description: 'Get changes since a cursor from session_get/session_changes. It returns only added messages when history is append-only; mode=reset means old history changed and should be fetched again.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: 'object', required: ['sessionId', 'cursor'], properties: {
|
|
106
|
+
sessionId: { type: 'string' }, agent: { type: 'string' }, cursor: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 2000 },
|
|
107
|
+
}, additionalProperties: false,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'session_create',
|
|
112
|
+
description: 'Create a real agent session by running its first prompt. Waits for the turn to finish and returns the newly assigned sessionId.',
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: 'object', required: ['prompt'], properties: {
|
|
115
|
+
agent: { type: 'string', enum: ['claude', 'codex', 'grok', 'gemini', 'cursor'] }, prompt: { type: 'string', minLength: 1 }, cwd: { type: 'string' },
|
|
116
|
+
model: { type: 'string' }, effort: { type: 'string' }, permissionMode: { type: 'string', enum: ['default', 'acceptEdits', 'auto', 'bypassPermissions'] },
|
|
117
|
+
}, additionalProperties: false,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'session_run',
|
|
122
|
+
description: 'Run one more prompt in an existing session. This is synchronous and returns a compact event transcript.',
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: 'object', required: ['sessionId', 'prompt'], properties: {
|
|
125
|
+
sessionId: { type: 'string' }, agent: { type: 'string' }, prompt: { type: 'string', minLength: 1 }, cwd: { type: 'string' },
|
|
126
|
+
model: { type: 'string' }, effort: { type: 'string' }, permissionMode: { type: 'string', enum: ['default', 'acceptEdits', 'auto', 'bypassPermissions'] },
|
|
127
|
+
}, additionalProperties: false,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'session_resume',
|
|
132
|
+
description: 'Alias of session_run with an explicit resume intent. Use it to continue a session with a new prompt.',
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: 'object', required: ['sessionId', 'prompt'], properties: {
|
|
135
|
+
sessionId: { type: 'string' }, agent: { type: 'string' }, prompt: { type: 'string', minLength: 1 }, cwd: { type: 'string' },
|
|
136
|
+
model: { type: 'string' }, effort: { type: 'string' }, permissionMode: { type: 'string', enum: ['default', 'acceptEdits', 'auto', 'bypassPermissions'] },
|
|
137
|
+
}, additionalProperties: false,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'session_abort',
|
|
142
|
+
description: 'Interrupt the active run for a session.',
|
|
143
|
+
inputSchema: { type: 'object', required: ['sessionId'], properties: { sessionId: { type: 'string' } }, additionalProperties: false },
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Create the MCP domain layer. Dependencies are injected so the protocol and
|
|
149
|
+
* cursor rules are testable without a running HTTP server or installed CLIs.
|
|
150
|
+
*/
|
|
151
|
+
export function createMcpService({ listSessions, readSession, dispatchAgent, abortSession, isSessionBusy, enabledAgents, toClientSession = (s) => s }) {
|
|
152
|
+
const validAgents = new Set(enabledAgents || []);
|
|
153
|
+
|
|
154
|
+
function agentArg(args, fallback = 'claude') {
|
|
155
|
+
const agent = (stringArg(args, 'agent', { max: 32 }) || fallback).toLowerCase();
|
|
156
|
+
if (!validAgents.has(agent)) throw new Error(`agent must be one of: ${[...validAgents].join(', ')}`);
|
|
157
|
+
return agent;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function sessionSnapshot(sessionId, preferredAgent) {
|
|
161
|
+
const bundle = await readSession(sessionId, preferredAgent);
|
|
162
|
+
if (!bundle) throw new Error('Session not found');
|
|
163
|
+
const messages = Array.isArray(bundle.messages) ? bundle.messages : [];
|
|
164
|
+
const agent = bundle.agent || preferredAgent || 'claude';
|
|
165
|
+
return { ...bundle, agent, messages };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function run(args, { creating = false, resumed = false } = {}) {
|
|
169
|
+
const prompt = stringArg(args, 'prompt', { required: true, max: 200_000 });
|
|
170
|
+
const agent = agentArg(args);
|
|
171
|
+
const sessionId = creating ? undefined : sessionIdArg(args);
|
|
172
|
+
if (sessionId && isSessionBusy(sessionId)) throw new Error('Session is already running');
|
|
173
|
+
const options = { sessionId, cwd: stringArg(args, 'cwd', { max: 4_000 }), model: stringArg(args, 'model', { max: 200 }), effort: stringArg(args, 'effort', { max: 100 }) };
|
|
174
|
+
// Codex otherwise defaults to yolo when no mode is supplied. MCP callers
|
|
175
|
+
// must explicitly opt into a broader permissionMode.
|
|
176
|
+
options.mode = agent === 'codex' ? 'suggest' : undefined;
|
|
177
|
+
options.permissionMode = stringArg(args, 'permissionMode', { max: 64 }) || 'acceptEdits';
|
|
178
|
+
const events = [];
|
|
179
|
+
let eventCount = 0;
|
|
180
|
+
let createdSessionId = sessionId || null;
|
|
181
|
+
let output = '';
|
|
182
|
+
await dispatchAgent(agent, prompt, options, (event) => {
|
|
183
|
+
if (event?.sessionId) createdSessionId = event.sessionId;
|
|
184
|
+
if (event?.type === 'assistant_delta' && typeof event.data === 'string') output += event.data;
|
|
185
|
+
if (event?.type === 'result' && typeof event.result === 'string') output += event.result;
|
|
186
|
+
eventCount++;
|
|
187
|
+
if (events.length < MAX_EVENTS) events.push(conciseEvent(event));
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
created: creating, resumed, agent, sessionId: createdSessionId || sessionId,
|
|
191
|
+
output: output.slice(-MAX_OUTPUT_CHARS), events, eventsTruncated: eventCount > MAX_EVENTS,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function call(name, args = {}) {
|
|
196
|
+
switch (name) {
|
|
197
|
+
case 'session_list': {
|
|
198
|
+
const agent = args.agent == null ? null : agentArg(args);
|
|
199
|
+
const cwd = stringArg(args, 'cwd', { max: 4_000 });
|
|
200
|
+
const since = args.updatedSince == null ? 0 : Number(args.updatedSince);
|
|
201
|
+
if (!Number.isFinite(since) || since < 0) throw new Error('updatedSince must be a Unix millisecond timestamp');
|
|
202
|
+
const limit = boundedInt(args.limit, 100, 1, 800);
|
|
203
|
+
let sessions = await listSessions(cwd || null, agent);
|
|
204
|
+
if (since) sessions = sessions.filter((s) => Number(s.updatedAt || 0) > since);
|
|
205
|
+
sessions.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
|
|
206
|
+
return { sessions: sessions.slice(0, limit).map(toClientSession), nextUpdatedSince: sessions[0]?.updatedAt || since || Date.now(), truncated: sessions.length > limit };
|
|
207
|
+
}
|
|
208
|
+
case 'session_get': {
|
|
209
|
+
const sessionId = sessionIdArg(args);
|
|
210
|
+
const snapshot = await sessionSnapshot(sessionId, stringArg(args, 'agent', { max: 32 }));
|
|
211
|
+
const tail = boundedInt(args.tail, 0, 0, 50_000);
|
|
212
|
+
const all = snapshot.messages;
|
|
213
|
+
const messages = tail > 0 ? all.slice(-tail) : all.slice(-MAX_HISTORY_MESSAGES);
|
|
214
|
+
const returnedFrom = all.length - messages.length;
|
|
215
|
+
return {
|
|
216
|
+
sessionId, agent: snapshot.agent, context: snapshot.context || null, total: all.length, returnedFrom, messages,
|
|
217
|
+
truncated: returnedFrom > 0,
|
|
218
|
+
cursor: encodeSessionCursor({ sessionId, agent: snapshot.agent, count: all.length, prefix: digest(all) }),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
case 'session_changes': {
|
|
222
|
+
const sessionId = sessionIdArg(args);
|
|
223
|
+
const cursor = decodeSessionCursor(stringArg(args, 'cursor', { required: true, max: 2_000 }));
|
|
224
|
+
if (!cursor || cursor.sessionId !== sessionId) throw new Error('cursor is invalid for this session');
|
|
225
|
+
const requestedAgent = stringArg(args, 'agent', { max: 32 });
|
|
226
|
+
if (requestedAgent && requestedAgent !== cursor.agent) throw new Error('cursor agent does not match agent');
|
|
227
|
+
const snapshot = await sessionSnapshot(sessionId, cursor.agent);
|
|
228
|
+
const limit = boundedInt(args.limit, MAX_HISTORY_MESSAGES, 1, MAX_HISTORY_MESSAGES);
|
|
229
|
+
const prefixMatches = snapshot.agent === cursor.agent && cursor.count <= snapshot.messages.length
|
|
230
|
+
&& digest(snapshot.messages.slice(0, cursor.count)) === cursor.prefix;
|
|
231
|
+
const nextCursor = encodeSessionCursor({ sessionId, agent: snapshot.agent, count: snapshot.messages.length, prefix: digest(snapshot.messages) });
|
|
232
|
+
if (!prefixMatches) {
|
|
233
|
+
return { mode: 'reset', sessionId, agent: snapshot.agent, total: snapshot.messages.length, cursor: nextCursor,
|
|
234
|
+
reason: 'session history was rewritten or the cursor is stale; call session_get again' };
|
|
235
|
+
}
|
|
236
|
+
const added = snapshot.messages.slice(cursor.count, cursor.count + limit);
|
|
237
|
+
return { mode: 'diff', sessionId, agent: snapshot.agent, from: cursor.count, total: snapshot.messages.length,
|
|
238
|
+
added, hasMore: cursor.count + added.length < snapshot.messages.length, cursor: nextCursor };
|
|
239
|
+
}
|
|
240
|
+
case 'session_create': return run(args, { creating: true });
|
|
241
|
+
case 'session_run': return run(args);
|
|
242
|
+
case 'session_resume': return run(args, { resumed: true });
|
|
243
|
+
case 'session_abort': {
|
|
244
|
+
const sessionId = sessionIdArg(args);
|
|
245
|
+
return { sessionId, aborted: !!abortSession(sessionId) };
|
|
246
|
+
}
|
|
247
|
+
default: throw new Error(`Unknown tool: ${name}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { call };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function createMcpHttpHandler({ service, authenticate }) {
|
|
255
|
+
return async (req, res) => {
|
|
256
|
+
const rpc = req.body;
|
|
257
|
+
const id = rpc?.id;
|
|
258
|
+
const reply = (result) => res.json({ jsonrpc: '2.0', id: id ?? null, result });
|
|
259
|
+
const rpcError = (code, message) => res.status(code === -32600 ? 400 : 200).json({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
|
|
260
|
+
if (!rpc || rpc.jsonrpc !== '2.0' || typeof rpc.method !== 'string') return rpcError(-32600, 'Invalid JSON-RPC request');
|
|
261
|
+
if (!authenticate(req)) return res.status(401).json({ jsonrpc: '2.0', id: id ?? null, error: { code: -32001, message: 'Valid MCP bearer token required' } });
|
|
262
|
+
if (rpc.method === 'initialize') {
|
|
263
|
+
return reply({ protocolVersion: MCP_PROTOCOL_VERSION, capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'claude-simple-sessions', version: '0.2.9' } });
|
|
264
|
+
}
|
|
265
|
+
if (rpc.method === 'notifications/initialized') return res.status(202).end();
|
|
266
|
+
if (rpc.method === 'ping') return reply({});
|
|
267
|
+
if (rpc.method === 'tools/list') return reply({ tools: MCP_TOOLS });
|
|
268
|
+
if (rpc.method === 'tools/call') {
|
|
269
|
+
if (typeof rpc.params?.name !== 'string') return rpcError(-32602, 'tools/call requires params.name');
|
|
270
|
+
try { return reply(await service.call(rpc.params.name, rpc.params.arguments || {})); }
|
|
271
|
+
catch (error) { return reply(toolError(error instanceof Error ? error.message : String(error))); }
|
|
272
|
+
}
|
|
273
|
+
return rpcError(-32601, `Method not found: ${rpc.method}`);
|
|
274
|
+
};
|
|
275
|
+
}
|