claude-simple 0.2.9 → 0.2.10

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