@sanurb/ringi 0.2.0 → 0.2.1

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/dist/mcp.mjs ADDED
@@ -0,0 +1,1061 @@
1
+ import { a as GitService, c as CommentService, d as ReviewId, i as ReviewService, l as TodoId, n as ExportService, o as getDiffSummary, p as ReviewSourceType, r as TodoService, s as parseDiff, t as CoreLive } from "./runtime.mjs";
2
+ import { execFileSync } from "node:child_process";
3
+ import * as Schema from "effect/Schema";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Context from "effect/Context";
6
+ import * as Layer from "effect/Layer";
7
+ import * as ManagedRuntime from "effect/ManagedRuntime";
8
+ import * as ConfigProvider from "effect/ConfigProvider";
9
+ import { stdin, stdout } from "node:process";
10
+ import * as vm from "node:vm";
11
+ import * as Duration from "effect/Duration";
12
+ import * as ParseResult from "effect/ParseResult";
13
+ //#region src/mcp/config.ts
14
+ const DEFAULT_DB_PATH = ".ringi/reviews.db";
15
+ const DEFAULT_MAX_OUTPUT_BYTES = 100 * 1024;
16
+ const DEFAULT_TIMEOUT_MS = 3e4;
17
+ const MAX_TIMEOUT_MS = 12e4;
18
+ var McpConfig = class extends Context.Tag("McpConfig")() {};
19
+ const McpConfigLive = (config) => Layer.succeed(McpConfig, config);
20
+ const resolveRepositoryRoot = (repoOverride) => {
21
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
22
+ cwd: repoOverride ?? process.cwd(),
23
+ encoding: "utf8"
24
+ }).trim();
25
+ };
26
+ const resolveDbPath = (repoRoot, dbPathOverride) => dbPathOverride ?? `${repoRoot}/${DEFAULT_DB_PATH}`;
27
+ const parseNumberFlag = (flagValue, fallback, name) => {
28
+ if (flagValue === void 0) return fallback;
29
+ const parsed = Number.parseInt(flagValue, 10);
30
+ if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`Invalid ${name}: expected a positive integer, received ${flagValue}`);
31
+ return parsed;
32
+ };
33
+ const resolveMcpConfig = (argv) => {
34
+ const args = [...argv];
35
+ const readonly = args.includes("--readonly");
36
+ const dbIndex = args.indexOf("--db-path");
37
+ const repoIndex = args.indexOf("--repo");
38
+ const timeoutIndex = args.indexOf("--timeout-ms");
39
+ const maxOutputIndex = args.indexOf("--max-output-bytes");
40
+ const repoRoot = resolveRepositoryRoot(repoIndex === -1 ? void 0 : args[repoIndex + 1]);
41
+ return {
42
+ cwd: process.cwd(),
43
+ dbPath: resolveDbPath(repoRoot, dbIndex === -1 ? void 0 : args[dbIndex + 1]),
44
+ defaultTimeoutMs: parseNumberFlag(timeoutIndex === -1 ? void 0 : args[timeoutIndex + 1], DEFAULT_TIMEOUT_MS, "timeout"),
45
+ maxOutputBytes: parseNumberFlag(maxOutputIndex === -1 ? void 0 : args[maxOutputIndex + 1], DEFAULT_MAX_OUTPUT_BYTES, "max output bytes"),
46
+ maxTimeoutMs: MAX_TIMEOUT_MS,
47
+ readonly,
48
+ repoRoot
49
+ };
50
+ };
51
+ //#endregion
52
+ //#region src/mcp/errors.ts
53
+ /**
54
+ * MCP execution domain errors.
55
+ *
56
+ * All errors that can occur during sandbox execution are modeled as typed,
57
+ * tagged errors for exhaustive handling via catchTag/catchTags.
58
+ */
59
+ /** Code validation failed (empty, too long, non-string). */
60
+ var InvalidCodeError = class extends Schema.TaggedError()("InvalidCodeError", { message: Schema.String }) {};
61
+ /** Timeout parameter is invalid (non-finite, zero, negative). */
62
+ var InvalidTimeoutError = class extends Schema.TaggedError()("InvalidTimeoutError", {
63
+ message: Schema.String,
64
+ received: Schema.Unknown
65
+ }) {};
66
+ /** Sandbox execution timed out. */
67
+ var ExecutionTimeoutError = class extends Schema.TaggedError()("ExecutionTimeoutError", {
68
+ message: Schema.String,
69
+ timeoutMs: Schema.Number
70
+ }) {};
71
+ /** Sandbox execution failed with an unrecoverable error. */
72
+ var SandboxExecutionError = class extends Schema.TaggedError()("SandboxExecutionError", {
73
+ error: Schema.Defect,
74
+ message: Schema.String
75
+ }) {};
76
+ //#endregion
77
+ //#region src/mcp/namespaces.ts
78
+ /**
79
+ * MCP sandbox namespace factories.
80
+ *
81
+ * Each factory creates a frozen namespace object suitable for injection into the
82
+ * vm.Context sandbox. Factories accept dependency-injected callbacks so they can
83
+ * be tested without an Effect runtime.
84
+ */
85
+ const PHASE_UNAVAILABLE_MESSAGE = "This capability is not available in the current server phase. Intelligence features require Phase 2.";
86
+ const createIntelligenceNamespace = () => Object.freeze({
87
+ getConfidence: (_reviewId) => Promise.reject(new Error(PHASE_UNAVAILABLE_MESSAGE)),
88
+ getImpacts: (_reviewId) => Promise.reject(new Error(PHASE_UNAVAILABLE_MESSAGE)),
89
+ getRelationships: (_reviewId) => Promise.reject(new Error(PHASE_UNAVAILABLE_MESSAGE)),
90
+ validate: (_options) => Promise.reject(new Error(PHASE_UNAVAILABLE_MESSAGE))
91
+ });
92
+ const createSessionNamespace = (deps) => Object.freeze({
93
+ context: async () => {
94
+ const repo = await deps.getRepositoryInfo();
95
+ return {
96
+ activeReviewId: await deps.getLatestReviewId(),
97
+ activeSnapshotId: null,
98
+ readonly: deps.readonly,
99
+ repository: repo,
100
+ serverMode: "stdio"
101
+ };
102
+ },
103
+ status: async () => ({
104
+ activeSubscriptions: 0,
105
+ currentPhase: "phase1",
106
+ ok: true,
107
+ readonly: deps.readonly
108
+ })
109
+ });
110
+ const buildPreview = (diffText, source) => {
111
+ const files = parseDiff(diffText);
112
+ const summary = getDiffSummary(files);
113
+ return {
114
+ files: files.map((f) => ({
115
+ additions: f.additions,
116
+ deletions: f.deletions,
117
+ path: f.newPath,
118
+ status: f.status
119
+ })),
120
+ source,
121
+ summary: {
122
+ totalAdditions: summary.totalAdditions,
123
+ totalDeletions: summary.totalDeletions,
124
+ totalFiles: summary.totalFiles
125
+ }
126
+ };
127
+ };
128
+ const createSourcesNamespace = (deps) => Object.freeze({
129
+ list: async () => {
130
+ const [stagedFiles, branches, commits] = await Promise.all([
131
+ deps.getStagedFiles(),
132
+ deps.getBranches(),
133
+ deps.getRecentCommits()
134
+ ]);
135
+ return {
136
+ branches: branches.map((b) => ({
137
+ current: b.current,
138
+ name: b.name
139
+ })),
140
+ recentCommits: commits.map((c) => ({
141
+ author: c.author,
142
+ date: c.date,
143
+ hash: c.hash,
144
+ message: c.message
145
+ })),
146
+ staged: { available: stagedFiles.length > 0 }
147
+ };
148
+ },
149
+ previewDiff: async (source) => {
150
+ let diffText;
151
+ switch (source.type) {
152
+ case "staged":
153
+ diffText = await deps.getStagedDiff();
154
+ break;
155
+ case "branch":
156
+ diffText = await deps.getBranchDiff(source.baseRef);
157
+ break;
158
+ case "commits":
159
+ diffText = await deps.getCommitDiff(source.commits);
160
+ break;
161
+ default: throw new Error(`Unsupported source type: ${source.type}`);
162
+ }
163
+ return buildPreview(diffText, source);
164
+ }
165
+ });
166
+ const createEventsNamespace = () => {
167
+ let subscriptionCounter = 0;
168
+ return Object.freeze({
169
+ listRecent: async (_filter) => [],
170
+ subscribe: async (filter) => {
171
+ subscriptionCounter += 1;
172
+ return {
173
+ eventTypes: filter?.eventTypes ?? [
174
+ "reviews.updated",
175
+ "comments.updated",
176
+ "todos.updated",
177
+ "files.changed"
178
+ ],
179
+ id: `sub_${subscriptionCounter}`,
180
+ reviewId: filter?.reviewId
181
+ };
182
+ }
183
+ });
184
+ };
185
+ //#endregion
186
+ //#region src/mcp/sandbox.ts
187
+ /**
188
+ * MCP sandbox global construction.
189
+ *
190
+ * Creates the six spec-compliant namespaces (`reviews`, `todos`, `sources`,
191
+ * `intelligence`, `events`, `session`) from injected dependencies.
192
+ *
193
+ * All domain interaction goes through the `call` callback so the sandbox module
194
+ * stays testable without an Effect runtime.
195
+ */
196
+ const parseId = (value, fieldName) => {
197
+ if (typeof value !== "string" || value.length === 0) throw new Error(`Invalid ${fieldName}: expected a non-empty string`);
198
+ return value;
199
+ };
200
+ /**
201
+ * Build the six frozen namespace objects for sandbox injection.
202
+ *
203
+ * The `call` callback wraps every domain operation with journal tracking and
204
+ * error handling. This keeps the sandbox module free of Effect imports.
205
+ */
206
+ const createSandboxGlobals = (deps) => {
207
+ const reviews = Object.freeze({
208
+ create: async (_input) => {
209
+ deps.requireWritable();
210
+ return deps.call("reviews.create", async () => {
211
+ throw new Error("reviews.create: not wired to runtime");
212
+ });
213
+ },
214
+ export: (_options) => deps.call("reviews.export", async () => {
215
+ throw new Error("reviews.export: not wired to runtime");
216
+ }),
217
+ get: (reviewIdValue) => {
218
+ parseId(reviewIdValue, "reviewId");
219
+ return deps.call("reviews.get", async () => {
220
+ throw new Error("reviews.get: not wired to runtime");
221
+ });
222
+ },
223
+ getComments: (reviewIdValue, _filePath) => {
224
+ parseId(reviewIdValue, "reviewId");
225
+ return deps.call("reviews.getComments", async () => {
226
+ throw new Error("reviews.getComments: not wired to runtime");
227
+ });
228
+ },
229
+ getDiff: (_query) => deps.call("reviews.getDiff", async () => {
230
+ throw new Error("reviews.getDiff: not wired to runtime");
231
+ }),
232
+ getFiles: (reviewIdValue) => {
233
+ parseId(reviewIdValue, "reviewId");
234
+ return deps.call("reviews.getFiles", async () => {
235
+ throw new Error("reviews.getFiles: not wired to runtime");
236
+ });
237
+ },
238
+ getStatus: (reviewIdValue) => {
239
+ parseId(reviewIdValue, "reviewId");
240
+ return deps.call("reviews.getStatus", async () => {
241
+ throw new Error("reviews.getStatus: not wired to runtime");
242
+ });
243
+ },
244
+ getSuggestions: (reviewIdValue) => {
245
+ parseId(reviewIdValue, "reviewId");
246
+ return deps.call("reviews.getSuggestions", async () => {
247
+ throw new Error("reviews.getSuggestions: not wired to runtime");
248
+ });
249
+ },
250
+ list: (_filters) => deps.call("reviews.list", async () => {
251
+ throw new Error("reviews.list: not wired to runtime");
252
+ })
253
+ });
254
+ const todos = Object.freeze({
255
+ add: async (_input) => {
256
+ deps.requireWritable();
257
+ return deps.call("todos.add", async () => {
258
+ throw new Error("todos.add: not wired to runtime");
259
+ });
260
+ },
261
+ clear: async (_reviewIdValue) => {
262
+ deps.requireWritable();
263
+ return deps.call("todos.clear", async () => {
264
+ throw new Error("todos.clear: not wired to runtime");
265
+ });
266
+ },
267
+ done: async (todoIdValue) => {
268
+ deps.requireWritable();
269
+ parseId(todoIdValue, "todoId");
270
+ return deps.call("todos.done", async () => {
271
+ throw new Error("todos.done: not wired to runtime");
272
+ });
273
+ },
274
+ list: (_filter) => deps.call("todos.list", async () => {
275
+ throw new Error("todos.list: not wired to runtime");
276
+ }),
277
+ move: async (todoIdValue, _positionValue) => {
278
+ deps.requireWritable();
279
+ parseId(todoIdValue, "todoId");
280
+ return deps.call("todos.move", async () => {
281
+ throw new Error("todos.move: not wired to runtime");
282
+ });
283
+ },
284
+ remove: async (todoIdValue) => {
285
+ deps.requireWritable();
286
+ parseId(todoIdValue, "todoId");
287
+ return deps.call("todos.remove", async () => {
288
+ throw new Error("todos.remove: not wired to runtime");
289
+ });
290
+ },
291
+ undone: async (todoIdValue) => {
292
+ deps.requireWritable();
293
+ parseId(todoIdValue, "todoId");
294
+ return deps.call("todos.undone", async () => {
295
+ throw new Error("todos.undone: not wired to runtime");
296
+ });
297
+ }
298
+ });
299
+ const sources = createSourcesNamespace({
300
+ getBranchDiff: deps.getBranchDiff,
301
+ getBranches: deps.getBranches,
302
+ getCommitDiff: deps.getCommitDiff,
303
+ getRecentCommits: deps.getRecentCommits,
304
+ getRepositoryInfo: deps.getRepositoryInfo,
305
+ getStagedDiff: deps.getStagedDiff,
306
+ getStagedFiles: deps.getStagedFiles
307
+ });
308
+ const intelligence = createIntelligenceNamespace();
309
+ return {
310
+ events: createEventsNamespace(),
311
+ intelligence,
312
+ reviews,
313
+ session: createSessionNamespace({
314
+ getLatestReviewId: deps.getLatestReviewId,
315
+ getRepositoryInfo: deps.getRepositoryInfo,
316
+ readonly: deps.readonly
317
+ }),
318
+ sources,
319
+ todos
320
+ };
321
+ };
322
+ //#endregion
323
+ //#region src/mcp/schemas.ts
324
+ /**
325
+ * MCP sandbox input schemas.
326
+ *
327
+ * Centralizes all validation for sandbox namespace inputs using Effect Schema.
328
+ * Replaces hand-written parse* helpers with declarative, composable schemas.
329
+ */
330
+ const NonEmptyString = Schema.String.pipe(Schema.minLength(1));
331
+ /**
332
+ * Spec shape: `{ source: { type, baseRef } }`.
333
+ * Legacy shape: `{ sourceType, sourceRef }`.
334
+ *
335
+ * We use Schema.Union to accept either and normalize to the legacy shape.
336
+ */
337
+ const ReviewCreateFromSpec = Schema.Struct({ source: Schema.Struct({
338
+ baseRef: Schema.optionalWith(Schema.NullOr(Schema.String), { default: () => null }),
339
+ type: Schema.optionalWith(ReviewSourceType, { default: () => "staged" })
340
+ }) });
341
+ const ReviewCreateFromLegacy = Schema.Struct({
342
+ sourceRef: Schema.optionalWith(Schema.NullOr(Schema.String), { default: () => null }),
343
+ sourceType: Schema.optionalWith(ReviewSourceType, { default: () => "staged" })
344
+ });
345
+ /** Decode unknown input into a normalized ReviewCreateInput. */
346
+ const decodeReviewCreateInput = (input) => {
347
+ if (typeof input === "object" && input !== null && "source" in input) {
348
+ const parsed = Schema.decodeUnknownSync(ReviewCreateFromSpec)(input);
349
+ return {
350
+ sourceRef: parsed.source.baseRef,
351
+ sourceType: parsed.source.type
352
+ };
353
+ }
354
+ const parsed = Schema.decodeUnknownSync(ReviewCreateFromLegacy)(input);
355
+ return {
356
+ sourceRef: parsed.sourceRef,
357
+ sourceType: parsed.sourceType
358
+ };
359
+ };
360
+ const ReviewExportInput = Schema.Struct({ reviewId: ReviewId });
361
+ const ReviewDiffQuery = Schema.Struct({
362
+ filePath: NonEmptyString,
363
+ reviewId: ReviewId
364
+ });
365
+ const ReviewListFilters = Schema.Struct({
366
+ limit: Schema.optionalWith(Schema.Number, { default: () => 20 }),
367
+ page: Schema.optionalWith(Schema.Number, { default: () => 1 }),
368
+ pageSize: Schema.optionalWith(Schema.Number, { default: () => 20 }),
369
+ sourceType: Schema.optional(Schema.String),
370
+ status: Schema.optional(Schema.String)
371
+ });
372
+ const TodoInputFromSpec = Schema.Struct({
373
+ reviewId: Schema.optionalWith(Schema.NullOr(ReviewId), { default: () => null }),
374
+ text: NonEmptyString
375
+ });
376
+ const TodoInputFromLegacy = Schema.Struct({
377
+ content: NonEmptyString,
378
+ reviewId: Schema.optionalWith(Schema.NullOr(ReviewId), { default: () => null })
379
+ });
380
+ /** Decode unknown input into a normalized CreateTodoInput. */
381
+ const decodeCreateTodoInput = (input) => {
382
+ if (typeof input === "object" && input !== null && "text" in input) {
383
+ const parsed = Schema.decodeUnknownSync(TodoInputFromSpec)(input);
384
+ return {
385
+ content: parsed.text,
386
+ reviewId: parsed.reviewId
387
+ };
388
+ }
389
+ const parsed = Schema.decodeUnknownSync(TodoInputFromLegacy)(input);
390
+ return {
391
+ content: parsed.content,
392
+ reviewId: parsed.reviewId
393
+ };
394
+ };
395
+ const TodoListFilter = Schema.Struct({ reviewId: Schema.optional(ReviewId) });
396
+ const TodoMoveInput = Schema.Struct({
397
+ position: Schema.Number,
398
+ todoId: TodoId
399
+ });
400
+ //#endregion
401
+ //#region src/mcp/execute.ts
402
+ const MAX_CODE_LENGTH = 5e4;
403
+ const MIN_PREVIEW_BYTES = 256;
404
+ const clampTimeout = (requestedTimeout, config) => {
405
+ if (requestedTimeout === void 0) return config.defaultTimeoutMs;
406
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error(`Invalid timeout: expected a positive integer, received ${requestedTimeout}`);
407
+ return Math.min(Math.trunc(requestedTimeout), config.maxTimeoutMs);
408
+ };
409
+ const formatError$1 = (error) => error instanceof Error ? error.message : String(error);
410
+ const truncateUtf8 = (text, maxBytes) => {
411
+ const buffer = Buffer.from(text, "utf8");
412
+ if (buffer.byteLength <= maxBytes) return text;
413
+ return buffer.subarray(0, maxBytes).toString("utf8");
414
+ };
415
+ const summarizeForJournal = (value) => {
416
+ if (typeof value === "string") return value.length > 200 ? `${value.slice(0, 200)}…` : value;
417
+ if (typeof value === "number" || typeof value === "boolean" || value === null || value === void 0) return value;
418
+ if (Array.isArray(value)) return {
419
+ kind: "array",
420
+ length: value.length
421
+ };
422
+ if (typeof value === "object") return {
423
+ keys: Object.keys(value).slice(0, 10),
424
+ kind: "object"
425
+ };
426
+ return typeof value;
427
+ };
428
+ const finalizeOutput = (output, maxOutputBytes) => {
429
+ const serialized = JSON.stringify(output);
430
+ if (Buffer.byteLength(serialized, "utf8") <= maxOutputBytes) return output;
431
+ const previewBudget = Math.max(MIN_PREVIEW_BYTES, maxOutputBytes - Math.min(1024, Math.floor(maxOutputBytes / 4)));
432
+ return {
433
+ ...output,
434
+ result: {
435
+ note: "Result truncated to fit MCP output budget",
436
+ preview: truncateUtf8(JSON.stringify(output.result), previewBudget)
437
+ },
438
+ truncated: true
439
+ };
440
+ };
441
+ const ensureCode = (code) => {
442
+ if (typeof code !== "string") throw new TypeError("Invalid code: expected a string");
443
+ const trimmed = code.trim();
444
+ if (trimmed.length === 0) throw new Error("Invalid code: expected a non-empty string");
445
+ if (trimmed.length > MAX_CODE_LENGTH) throw new Error(`Invalid code: maximum length is ${MAX_CODE_LENGTH} characters`);
446
+ return trimmed;
447
+ };
448
+ /** Synchronous decode — throws on failure (for use inside Promise-returning sandbox callbacks). */
449
+ const decodeInputSync = (schema, input, operation) => {
450
+ try {
451
+ return Schema.decodeUnknownSync(schema)(input);
452
+ } catch (error) {
453
+ if (error instanceof ParseResult.ParseError) throw new TypeError(`${operation}: ${ParseResult.TreeFormatter.formatErrorSync(error)}`, { cause: error });
454
+ throw error;
455
+ }
456
+ };
457
+ const validateCode = (code) => Effect.try({
458
+ catch: (e) => new InvalidCodeError({ message: formatError$1(e) }),
459
+ try: () => ensureCode(code)
460
+ });
461
+ const validateTimeout = (requested, config) => Effect.try({
462
+ catch: () => new InvalidTimeoutError({
463
+ message: `Invalid timeout: expected a positive integer, received ${requested}`,
464
+ received: requested
465
+ }),
466
+ try: () => clampTimeout(requested, config)
467
+ });
468
+ const writeSandboxLog = (level, args) => {
469
+ const line = args.map((arg) => {
470
+ if (typeof arg === "string") return arg;
471
+ try {
472
+ return JSON.stringify(arg);
473
+ } catch {
474
+ return String(arg);
475
+ }
476
+ }).join(" ");
477
+ process.stderr.write(`[ringi:mcp:${level}] ${line}\n`);
478
+ };
479
+ const createSandboxConsole = () => Object.freeze({
480
+ error: (...args) => writeSandboxLog("error", args),
481
+ info: (...args) => writeSandboxLog("info", args),
482
+ log: (...args) => writeSandboxLog("log", args),
483
+ warn: (...args) => writeSandboxLog("warn", args)
484
+ });
485
+ /**
486
+ * Runs user code in a VM sandbox with a single, deterministic timeout model:
487
+ *
488
+ * 1. `vm.Script.runInContext({ timeout })` — CPU-bound timeout for sync JS
489
+ * 2. `Effect.timeoutFail` — wall-clock timeout for the full async execution
490
+ *
491
+ * No `Promise.race` — timeout is handled by fiber interruption which properly
492
+ * cleans up resources. The vm timeout catches infinite sync loops while the
493
+ * Effect timeout catches slow async operations.
494
+ */
495
+ const runSandbox = (globals, code, timeoutMs) => {
496
+ return Effect.tryPromise({
497
+ catch: (error) => new SandboxExecutionError({
498
+ error,
499
+ message: formatError$1(error)
500
+ }),
501
+ try: () => {
502
+ const context = vm.createContext({
503
+ ...globals,
504
+ Buffer: void 0,
505
+ clearImmediate: void 0,
506
+ clearInterval: void 0,
507
+ clearTimeout: void 0,
508
+ console: createSandboxConsole(),
509
+ fetch: void 0,
510
+ process: void 0,
511
+ queueMicrotask,
512
+ require: void 0,
513
+ setImmediate: void 0,
514
+ setInterval: void 0,
515
+ setTimeout: void 0
516
+ });
517
+ const script = new vm.Script(`"use strict"; (async () => {\n${code}\n})()`, { filename: "ringi-mcp-execute.js" });
518
+ return Promise.resolve(script.runInContext(context, { timeout: timeoutMs }));
519
+ }
520
+ }).pipe(Effect.timeoutFail({
521
+ duration: Duration.millis(timeoutMs),
522
+ onTimeout: () => new ExecutionTimeoutError({
523
+ message: `Execution timed out after ${timeoutMs}ms`,
524
+ timeoutMs
525
+ })
526
+ }));
527
+ };
528
+ const createJournal = () => {
529
+ const entries = [];
530
+ const recordSuccess = (name, result) => {
531
+ entries.push({
532
+ name,
533
+ ok: true,
534
+ result: summarizeForJournal(result)
535
+ });
536
+ };
537
+ const recordFailure = (name, error) => {
538
+ entries.push({
539
+ error: formatError$1(error),
540
+ name,
541
+ ok: false
542
+ });
543
+ };
544
+ /**
545
+ * Runs a runtime Effect with journal tracking. Returns a Promise for sandbox use.
546
+ *
547
+ * The R parameter accepts any subset of McpRuntimeContext — the managed runtime
548
+ * provides all services, so any effect requiring a subset of them is safe to run.
549
+ */
550
+ const tracked = (runtime, name, effect) => runtime.runPromise(effect).then((result) => {
551
+ recordSuccess(name, result);
552
+ return result;
553
+ }, (error) => {
554
+ recordFailure(name, error);
555
+ throw error;
556
+ });
557
+ /** Promise-based call wrapper for non-Effect operations. */
558
+ const trackedAsync = async (name, fn) => {
559
+ try {
560
+ const result = await fn();
561
+ recordSuccess(name, result);
562
+ return result;
563
+ } catch (error) {
564
+ recordFailure(name, error);
565
+ throw error;
566
+ }
567
+ };
568
+ return {
569
+ entries,
570
+ recordFailure,
571
+ recordSuccess,
572
+ tracked,
573
+ trackedAsync
574
+ };
575
+ };
576
+ const buildSandboxGlobals = (runtime, config, journal) => {
577
+ const { tracked, trackedAsync } = journal;
578
+ const throwIfReadonly = () => {
579
+ if (config.readonly) throw new Error("Mutation rejected: MCP server is running in readonly mode");
580
+ };
581
+ const run = (name, effect) => tracked(runtime, name, effect);
582
+ const baseGlobals = createSandboxGlobals({
583
+ call: trackedAsync,
584
+ getBranchDiff: (branch) => run("git.getBranchDiff", Effect.gen(function* getBranchDiff() {
585
+ return yield* (yield* GitService).getBranchDiff(branch);
586
+ })),
587
+ getBranches: () => run("git.getBranches", Effect.gen(function* getBranches() {
588
+ return yield* (yield* GitService).getBranches;
589
+ })),
590
+ getCommitDiff: (shas) => run("git.getCommitDiff", Effect.gen(function* getCommitDiff() {
591
+ return yield* (yield* GitService).getCommitDiff(shas);
592
+ })),
593
+ getLatestReviewId: async () => {
594
+ return (await run("reviews.latestId", Effect.gen(function* result() {
595
+ return yield* (yield* ReviewService).list({
596
+ page: 1,
597
+ pageSize: 1,
598
+ repositoryPath: config.repoRoot
599
+ });
600
+ }))).reviews[0]?.id ?? null;
601
+ },
602
+ getRecentCommits: async () => {
603
+ return (await run("git.getCommits", Effect.gen(function* result() {
604
+ return yield* (yield* GitService).getCommits({
605
+ limit: 10,
606
+ offset: 0
607
+ });
608
+ }))).commits;
609
+ },
610
+ getRepositoryInfo: () => run("git.getRepositoryInfo", Effect.gen(function* getRepositoryInfo() {
611
+ return yield* (yield* GitService).getRepositoryInfo;
612
+ })),
613
+ getStagedDiff: () => run("git.getStagedDiff", Effect.gen(function* getStagedDiff() {
614
+ return yield* (yield* GitService).getStagedDiff;
615
+ })),
616
+ getStagedFiles: () => run("git.getStagedFiles", Effect.gen(function* getStagedFiles() {
617
+ return yield* (yield* GitService).getStagedFiles;
618
+ })),
619
+ readonly: config.readonly,
620
+ repoRoot: config.repoRoot,
621
+ requireWritable: throwIfReadonly
622
+ });
623
+ const reviews = Object.freeze({
624
+ create: async (inputValue) => {
625
+ throwIfReadonly();
626
+ const parsed = decodeReviewCreateInput(inputValue);
627
+ return run("reviews.create", Effect.gen(function* create() {
628
+ return yield* (yield* ReviewService).create(parsed);
629
+ }));
630
+ },
631
+ export: async (options) => {
632
+ const opts = decodeInputSync(ReviewExportInput, options, "reviews.export");
633
+ return {
634
+ markdown: await run("reviews.export", Effect.gen(function* markdown() {
635
+ return yield* (yield* ExportService).exportReview(opts.reviewId);
636
+ })),
637
+ reviewId: opts.reviewId
638
+ };
639
+ },
640
+ get: (reviewIdValue) => {
641
+ const reviewId = reviewIdValue;
642
+ return run("reviews.get", Effect.gen(function* get() {
643
+ return yield* (yield* ReviewService).getById(reviewId);
644
+ }));
645
+ },
646
+ getComments: (reviewIdValue, filePath) => {
647
+ const reviewId = reviewIdValue;
648
+ if (filePath !== null && filePath !== void 0 && typeof filePath !== "string") return Promise.reject(/* @__PURE__ */ new Error("Invalid filePath: expected a string"));
649
+ return run("reviews.getComments", Effect.gen(function* getComments() {
650
+ const svc = yield* CommentService;
651
+ return filePath ? yield* svc.getByFile(reviewId, filePath) : yield* svc.getByReview(reviewId);
652
+ }));
653
+ },
654
+ getDiff: async (query) => {
655
+ const q = decodeInputSync(ReviewDiffQuery, query, "reviews.getDiff");
656
+ const hunks = await run(`reviews.getDiff:${q.filePath}`, Effect.gen(function* hunks() {
657
+ return yield* (yield* ReviewService).getFileHunks(q.reviewId, q.filePath);
658
+ }));
659
+ return {
660
+ filePath: q.filePath,
661
+ hunks,
662
+ reviewId: q.reviewId
663
+ };
664
+ },
665
+ getFiles: async (reviewIdValue) => {
666
+ const reviewId = reviewIdValue;
667
+ return (await run("reviews.getFiles", Effect.gen(function* review() {
668
+ return yield* (yield* ReviewService).getById(reviewId);
669
+ }))).files;
670
+ },
671
+ getStatus: async (reviewIdValue) => {
672
+ const reviewId = reviewIdValue;
673
+ const [review, stats] = await Promise.all([run("reviews.getStatus.review", Effect.gen(function* () {
674
+ return yield* (yield* ReviewService).getById(reviewId);
675
+ })), run("reviews.getStatus.comments", Effect.gen(function* () {
676
+ return yield* (yield* CommentService).getStats(reviewId);
677
+ }))]);
678
+ return {
679
+ resolvedComments: stats.resolved,
680
+ reviewId,
681
+ status: review.status,
682
+ totalComments: stats.total,
683
+ unresolvedComments: stats.unresolved,
684
+ withSuggestions: 0
685
+ };
686
+ },
687
+ getSuggestions: async (reviewIdValue) => {
688
+ const reviewId = reviewIdValue;
689
+ return (await run("reviews.getSuggestions", Effect.gen(function* comments() {
690
+ return yield* (yield* CommentService).getByReview(reviewId);
691
+ }))).filter((c) => c.suggestion != null).map((c) => ({
692
+ commentId: c.id,
693
+ id: c.id,
694
+ originalCode: "",
695
+ suggestedCode: c.suggestion ?? ""
696
+ }));
697
+ },
698
+ list: async (filters) => {
699
+ const parsed = decodeInputSync(ReviewListFilters, filters ?? {}, "reviews.list");
700
+ return run("reviews.list", Effect.gen(function* list() {
701
+ return yield* (yield* ReviewService).list({
702
+ page: parsed.page,
703
+ pageSize: parsed.limit,
704
+ repositoryPath: config.repoRoot,
705
+ sourceType: parsed.sourceType,
706
+ status: parsed.status
707
+ });
708
+ }));
709
+ }
710
+ });
711
+ const todos = Object.freeze({
712
+ add: async (inputValue) => {
713
+ throwIfReadonly();
714
+ const parsed = decodeCreateTodoInput(inputValue);
715
+ return run("todos.add", Effect.gen(function* add() {
716
+ return yield* (yield* TodoService).create(parsed);
717
+ }));
718
+ },
719
+ clear: async (_reviewIdValue) => {
720
+ throwIfReadonly();
721
+ return run("todos.clear", Effect.gen(function* clear() {
722
+ return {
723
+ removed: (yield* (yield* TodoService).removeCompleted()).deleted,
724
+ success: true
725
+ };
726
+ }));
727
+ },
728
+ done: async (todoIdValue) => {
729
+ throwIfReadonly();
730
+ const todoId = todoIdValue;
731
+ return run("todos.done", Effect.gen(function* done() {
732
+ const svc = yield* TodoService;
733
+ const todo = yield* svc.getById(todoId);
734
+ if (todo.completed) return todo;
735
+ return yield* svc.toggle(todoId);
736
+ }));
737
+ },
738
+ list: async (filter) => {
739
+ const parsed = decodeInputSync(TodoListFilter, filter ?? {}, "todos.list");
740
+ return (await run("todos.list", Effect.gen(function* result() {
741
+ return yield* (yield* TodoService).list({ reviewId: parsed.reviewId });
742
+ }))).data;
743
+ },
744
+ move: async (todoIdValue, positionValue) => {
745
+ throwIfReadonly();
746
+ const parsed = decodeInputSync(TodoMoveInput, {
747
+ position: positionValue,
748
+ todoId: todoIdValue
749
+ }, "todos.move");
750
+ return run("todos.move", Effect.gen(function* move() {
751
+ return yield* (yield* TodoService).move(parsed.todoId, parsed.position);
752
+ }));
753
+ },
754
+ remove: async (todoIdValue) => {
755
+ throwIfReadonly();
756
+ const todoId = todoIdValue;
757
+ return run("todos.remove", Effect.gen(function* remove() {
758
+ return yield* (yield* TodoService).remove(todoId);
759
+ }));
760
+ },
761
+ undone: async (todoIdValue) => {
762
+ throwIfReadonly();
763
+ const todoId = todoIdValue;
764
+ return run("todos.undone", Effect.gen(function* undone() {
765
+ const svc = yield* TodoService;
766
+ const todo = yield* svc.getById(todoId);
767
+ if (!todo.completed) return todo;
768
+ return yield* svc.toggle(todoId);
769
+ }));
770
+ }
771
+ });
772
+ return {
773
+ events: baseGlobals.events,
774
+ intelligence: baseGlobals.intelligence,
775
+ reviews,
776
+ session: baseGlobals.session,
777
+ sources: baseGlobals.sources,
778
+ todos
779
+ };
780
+ };
781
+ /**
782
+ * Execute sandboxed user code against the Ringi core services.
783
+ *
784
+ * The execution pipeline is an Effect that:
785
+ * - Validates code and timeout via typed errors
786
+ * - Builds schema-validated sandbox namespaces
787
+ * - Runs the VM sandbox with a deterministic timeout model
788
+ * (vm.Script timeout for sync loops + Effect.timeoutFail for async wall-clock)
789
+ * - Propagates errors via typed tagged errors, never raw throws
790
+ *
791
+ * The managed runtime is passed explicitly — it provides the concrete service
792
+ * environment. Individual sandbox namespace methods use `runtime.runPromise`
793
+ * because they are called from user JS inside the vm sandbox (Promise boundary).
794
+ */
795
+ const executeCode = (runtime, config, input) => Effect.gen(function* executeCode() {
796
+ const timeoutMs = yield* validateTimeout(input.timeout, config);
797
+ const code = yield* validateCode(input.code);
798
+ const journal = createJournal();
799
+ return finalizeOutput(yield* runSandbox(buildSandboxGlobals(runtime, config, journal), code, timeoutMs).pipe(Effect.catchTags({
800
+ ExecutionTimeoutError: (e) => Effect.succeed({
801
+ error: e.message,
802
+ ok: false,
803
+ result: journal.entries.length === 0 ? null : { operations: journal.entries }
804
+ }),
805
+ SandboxExecutionError: (e) => Effect.succeed({
806
+ error: e.message,
807
+ ok: false,
808
+ result: journal.entries.length === 0 ? null : { operations: journal.entries }
809
+ })
810
+ }), Effect.map((result) => {
811
+ if (typeof result === "object" && result !== null && "ok" in result && result.ok === false) return result;
812
+ return {
813
+ ok: true,
814
+ result
815
+ };
816
+ })), config.maxOutputBytes);
817
+ });
818
+ /**
819
+ * Runs the Effect-based `executeCode` pipeline against the managed runtime.
820
+ * This is the ONLY place `runtime.runPromise` is called for the execution
821
+ * pipeline — the server boundary.
822
+ *
823
+ * Typed errors (InvalidCodeError, InvalidTimeoutError) are caught and
824
+ * formatted as ExecuteOutput instead of propagating as rejections.
825
+ */
826
+ const executeCodeToPromise = async (runtime, config, input) => {
827
+ const program = executeCode(runtime, config, input).pipe(Effect.catchTags({
828
+ InvalidCodeError: (e) => Effect.succeed({
829
+ error: e.message,
830
+ ok: false,
831
+ result: null
832
+ }),
833
+ InvalidTimeoutError: (e) => Effect.succeed({
834
+ error: e.message,
835
+ ok: false,
836
+ result: null
837
+ })
838
+ }));
839
+ try {
840
+ return await Effect.runPromise(program);
841
+ } catch (error) {
842
+ return finalizeOutput({
843
+ error: formatError$1(error),
844
+ ok: false,
845
+ result: null
846
+ }, config.maxOutputBytes);
847
+ }
848
+ };
849
+ //#endregion
850
+ //#region src/mcp/runtime.ts
851
+ const makeConfigLayer = (config) => Layer.setConfigProvider(ConfigProvider.fromMap(new Map([["DB_PATH", config.dbPath], ["REPOSITORY_PATH", config.repoRoot]])));
852
+ const makeMcpLayer = (config) => Layer.mergeAll(CoreLive, McpConfigLive(config)).pipe(Layer.provideMerge(makeConfigLayer(config)));
853
+ const createMcpRuntime = (config) => ManagedRuntime.make(makeMcpLayer(config));
854
+ //#endregion
855
+ //#region src/mcp/server.ts
856
+ const JSON_RPC_VERSION = "2.0";
857
+ const LATEST_PROTOCOL_VERSION = "2025-11-25";
858
+ const MCP_SERVER_NAME = "ringi";
859
+ const MCP_SERVER_VERSION = process.env.npm_package_version ?? "0.0.0-dev";
860
+ const EXECUTE_TOOL = {
861
+ description: "Run constrained JavaScript against Ringi review namespaces: review, todo, comment, diff, export, and session.",
862
+ inputSchema: {
863
+ additionalProperties: false,
864
+ properties: {
865
+ code: {
866
+ description: "JavaScript snippet to evaluate inside the Ringi MCP sandbox.",
867
+ type: "string"
868
+ },
869
+ timeout: {
870
+ description: "Optional timeout in milliseconds. Defaults to 30000 and clamps at 120000.",
871
+ type: "number"
872
+ }
873
+ },
874
+ required: ["code"],
875
+ type: "object"
876
+ },
877
+ name: "execute",
878
+ outputSchema: {
879
+ additionalProperties: false,
880
+ properties: {
881
+ error: { type: "string" },
882
+ ok: { type: "boolean" },
883
+ result: {},
884
+ truncated: { type: "boolean" }
885
+ },
886
+ required: ["ok", "result"],
887
+ type: "object"
888
+ }
889
+ };
890
+ const writeStderr = (message) => {
891
+ process.stderr.write(`[ringi:mcp] ${message}\n`);
892
+ };
893
+ const writeMessage = (message) => {
894
+ const body = JSON.stringify(message);
895
+ const payload = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
896
+ stdout.write(payload);
897
+ };
898
+ const parseContentLength = (headerText) => {
899
+ const headerLine = headerText.split("\r\n").find((line) => line.toLowerCase().startsWith("content-length:"));
900
+ if (!headerLine) throw new Error("Missing Content-Length header");
901
+ const rawValue = headerLine.slice(headerLine.indexOf(":") + 1).trim();
902
+ const parsed = Number.parseInt(rawValue, 10);
903
+ if (!Number.isFinite(parsed) || parsed < 0) throw new Error(`Invalid Content-Length header: ${rawValue}`);
904
+ return parsed;
905
+ };
906
+ const formatError = (error) => {
907
+ if (error instanceof Error) return error.message;
908
+ return String(error);
909
+ };
910
+ const sendError = (id, code, message) => {
911
+ writeMessage({
912
+ error: {
913
+ code,
914
+ message
915
+ },
916
+ id,
917
+ jsonrpc: JSON_RPC_VERSION
918
+ });
919
+ };
920
+ const sendResult = (id, result) => {
921
+ writeMessage({
922
+ id,
923
+ jsonrpc: JSON_RPC_VERSION,
924
+ result
925
+ });
926
+ };
927
+ const createInitializeResult = () => ({
928
+ capabilities: { tools: { listChanged: false } },
929
+ protocolVersion: LATEST_PROTOCOL_VERSION,
930
+ serverInfo: {
931
+ description: "Local-first MCP codemode adapter over the Ringi core runtime.",
932
+ name: MCP_SERVER_NAME,
933
+ version: MCP_SERVER_VERSION
934
+ }
935
+ });
936
+ var StdioJsonRpcServer = class {
937
+ config = resolveMcpConfig(process.argv.slice(2));
938
+ runtime = createMcpRuntime(this.config);
939
+ buffer = Buffer.alloc(0);
940
+ initialized = false;
941
+ shuttingDown = false;
942
+ start() {
943
+ stdin.on("data", async (chunk) => {
944
+ this.buffer = Buffer.concat([this.buffer, chunk]);
945
+ try {
946
+ await this.drainBuffer();
947
+ } catch (error) {
948
+ writeStderr(`fatal buffer drain error: ${formatError(error)}`);
949
+ }
950
+ });
951
+ stdin.on("end", async () => {
952
+ await this.runtime.dispose();
953
+ });
954
+ process.on("SIGINT", async () => {
955
+ await this.close(0);
956
+ });
957
+ process.on("SIGTERM", async () => {
958
+ await this.close(0);
959
+ });
960
+ writeStderr(`server started readonly=${String(this.config.readonly)} repo=${this.config.repoRoot}`);
961
+ }
962
+ async close(code) {
963
+ await this.runtime.dispose();
964
+ process.exit(code);
965
+ }
966
+ async drainBuffer() {
967
+ while (true) {
968
+ const headerEnd = this.buffer.indexOf("\r\n\r\n");
969
+ if (headerEnd === -1) return;
970
+ const contentLength = parseContentLength(this.buffer.subarray(0, headerEnd).toString("utf8"));
971
+ const messageEnd = headerEnd + 4 + contentLength;
972
+ if (this.buffer.byteLength < messageEnd) return;
973
+ const payload = this.buffer.subarray(headerEnd + 4, messageEnd).toString("utf8");
974
+ this.buffer = this.buffer.subarray(messageEnd);
975
+ let message;
976
+ try {
977
+ message = JSON.parse(payload);
978
+ } catch (error) {
979
+ sendError(null, -32700, `Parse error: ${formatError(error)}`);
980
+ continue;
981
+ }
982
+ await this.handleMessage(message);
983
+ }
984
+ }
985
+ async handleMessage(message) {
986
+ if (typeof message.method !== "string") {
987
+ sendError(message.id ?? null, -32600, "Invalid JSON-RPC request");
988
+ return;
989
+ }
990
+ try {
991
+ if (await this.handleLifecycleMessage(message)) return;
992
+ this.assertInitialized();
993
+ if (message.method === "tools/list") {
994
+ sendResult(message.id ?? null, { tools: [EXECUTE_TOOL] });
995
+ return;
996
+ }
997
+ if (message.method === "tools/call") {
998
+ await this.handleToolCall(message.id ?? null, message.params);
999
+ return;
1000
+ }
1001
+ sendError(message.id ?? null, -32601, `Method not found: ${message.method}`);
1002
+ } catch (error) {
1003
+ sendError(message.id ?? null, -32603, formatError(error));
1004
+ }
1005
+ }
1006
+ async handleLifecycleMessage(message) {
1007
+ if (message.method === "initialize") {
1008
+ this.initialized = true;
1009
+ sendResult(message.id ?? null, createInitializeResult());
1010
+ return true;
1011
+ }
1012
+ if (message.method === "notifications/initialized") return true;
1013
+ if (message.method === "ping") {
1014
+ sendResult(message.id ?? null, {});
1015
+ return true;
1016
+ }
1017
+ if (message.method === "shutdown") {
1018
+ this.shuttingDown = true;
1019
+ sendResult(message.id ?? null, {});
1020
+ return true;
1021
+ }
1022
+ if (message.method === "exit") {
1023
+ await this.close(this.shuttingDown ? 0 : 1);
1024
+ return true;
1025
+ }
1026
+ return false;
1027
+ }
1028
+ assertInitialized() {
1029
+ if (!this.initialized) throw new Error("Server is not initialized");
1030
+ if (this.shuttingDown) throw new Error("Server is shutting down");
1031
+ }
1032
+ async handleToolCall(id, params) {
1033
+ if (!params || typeof params.name !== "string") {
1034
+ sendError(id, -32602, "Invalid tools/call params");
1035
+ return;
1036
+ }
1037
+ if (params.name !== EXECUTE_TOOL.name) {
1038
+ sendError(id, -32602, `Tool not found: ${String(params.name)}`);
1039
+ return;
1040
+ }
1041
+ const argumentsValue = params.arguments;
1042
+ if (typeof argumentsValue !== "object" || argumentsValue === null) {
1043
+ sendError(id, -32602, "Invalid tools/call arguments");
1044
+ return;
1045
+ }
1046
+ const executeResult = await executeCodeToPromise(this.runtime, this.config, argumentsValue);
1047
+ sendResult(id, {
1048
+ content: [{
1049
+ text: JSON.stringify(executeResult),
1050
+ type: "text"
1051
+ }],
1052
+ isError: !executeResult.ok,
1053
+ structuredContent: executeResult
1054
+ });
1055
+ }
1056
+ };
1057
+ new StdioJsonRpcServer().start();
1058
+ //#endregion
1059
+ export {};
1060
+
1061
+ //# sourceMappingURL=mcp.mjs.map