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/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
+ }