@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/cli.mjs +1741 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/mcp.mjs +1061 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/runtime.mjs +2975 -0
- package/dist/runtime.mjs.map +1 -0
- package/package.json +9 -9
- package/dist/chunk-KMYSGMD3.js +0 -3526
- package/dist/chunk-KMYSGMD3.js.map +0 -1
- package/dist/cli.js +0 -1839
- package/dist/cli.js.map +0 -1
- package/dist/mcp.js +0 -1228
- package/dist/mcp.js.map +0 -1
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
|