@achieveai/hitl-mcp-server 2.9.6 → 2.11.0
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.d.ts +31 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +133 -5
- package/dist/cli.js.map +1 -1
- package/dist/git-context.d.ts +41 -2
- package/dist/git-context.d.ts.map +1 -1
- package/dist/git-context.js +86 -2
- package/dist/git-context.js.map +1 -1
- package/dist/host-settings.d.ts +68 -0
- package/dist/host-settings.d.ts.map +1 -0
- package/dist/host-settings.js +136 -0
- package/dist/host-settings.js.map +1 -0
- package/dist/mcp-server.d.ts +54 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +410 -65
- package/dist/mcp-server.js.map +1 -1
- package/dist/ntfy-transport.d.ts +285 -13
- package/dist/ntfy-transport.d.ts.map +1 -1
- package/dist/ntfy-transport.js +747 -88
- package/dist/ntfy-transport.js.map +1 -1
- package/dist/payload.d.ts +80 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +135 -0
- package/dist/payload.js.map +1 -0
- package/dist/plan-diff.d.ts +29 -0
- package/dist/plan-diff.d.ts.map +1 -0
- package/dist/plan-diff.js +138 -0
- package/dist/plan-diff.js.map +1 -0
- package/dist/plan-file.d.ts +24 -0
- package/dist/plan-file.d.ts.map +1 -0
- package/dist/plan-file.js +98 -0
- package/dist/plan-file.js.map +1 -0
- package/dist/plan-review.d.ts +54 -0
- package/dist/plan-review.d.ts.map +1 -0
- package/dist/plan-review.js +110 -0
- package/dist/plan-review.js.map +1 -0
- package/dist/setup.d.ts +22 -5
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +52 -15
- package/dist/setup.js.map +1 -1
- package/dist/snapshot-store.d.ts +104 -0
- package/dist/snapshot-store.d.ts.map +1 -0
- package/dist/snapshot-store.js +209 -0
- package/dist/snapshot-store.js.map +1 -0
- package/dist/types.d.ts +114 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +13 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +15 -0
- package/dist/version.js.map +1 -0
- package/package.json +64 -61
- package/dist/bin/windows-x64/hitl-client.exe +0 -0
package/dist/mcp-server.d.ts
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import type { HitlConfig } from './types.js';
|
|
3
|
+
export declare class HumanInTheLoopServer {
|
|
4
|
+
private server;
|
|
5
|
+
private transport;
|
|
6
|
+
private config;
|
|
7
|
+
/** reviewIds still waiting on a human, so a graceful exit can release them (D-3). */
|
|
8
|
+
private outstandingReviews;
|
|
9
|
+
/** `config` is injectable so a test can construct a server without a real ~/.hitl. */
|
|
10
|
+
constructor(config?: HitlConfig);
|
|
11
|
+
private setupHandlers;
|
|
12
|
+
/**
|
|
13
|
+
* ReviewPlan: snapshot a markdown plan, publish it for line-anchored review,
|
|
14
|
+
* and block until a human submits a verdict.
|
|
15
|
+
*/
|
|
16
|
+
private handleReviewPlan;
|
|
17
|
+
/**
|
|
18
|
+
* Drop a subscription hold after the late-response window.
|
|
19
|
+
*
|
|
20
|
+
* The timer is unref'd so it can never be the reason the process stays alive.
|
|
21
|
+
*/
|
|
22
|
+
private releaseLater;
|
|
23
|
+
/**
|
|
24
|
+
* Download, decode and validate a submitted review, then acknowledge it.
|
|
25
|
+
*
|
|
26
|
+
* The ack is what lets the client distinguish "the server read this" from "I
|
|
27
|
+
* clicked submit": the response attachment expires after 3 h while the
|
|
28
|
+
* message itself survives 12 h, so a submit can be accepted at click time and
|
|
29
|
+
* still never arrive (C-12).
|
|
30
|
+
*/
|
|
31
|
+
private consumeReviewResponse;
|
|
32
|
+
private downloadResponseBody;
|
|
33
|
+
/** Tell the client whether its submission actually landed. */
|
|
34
|
+
private publishAck;
|
|
35
|
+
/**
|
|
36
|
+
* Fail before publishing when no client can be found or launched.
|
|
37
|
+
*
|
|
38
|
+
* Without a timeout to bound it, publishing to a topic nobody is subscribed
|
|
39
|
+
* to is a permanent silent hang — the agent blocks and the human never sees a
|
|
40
|
+
* window to explain why (A-10).
|
|
41
|
+
*/
|
|
42
|
+
private requireClient;
|
|
43
|
+
/**
|
|
44
|
+
* Start emitting progress notifications, returning the stop function.
|
|
45
|
+
*
|
|
46
|
+
* Whether these actually keep the call alive is the calling host's decision:
|
|
47
|
+
* the MCP SDK's DEFAULT_REQUEST_TIMEOUT_MSEC is 60 s and
|
|
48
|
+
* `resetTimeoutOnProgress` defaults to false, so a host that does not opt in
|
|
49
|
+
* will time the call out regardless of what we send (D-2).
|
|
50
|
+
*/
|
|
51
|
+
private startHeartbeat;
|
|
52
|
+
run(): Promise<void>;
|
|
53
|
+
/** Publish cancel_review for every review still waiting on a human. */
|
|
54
|
+
private cancelOutstandingReviews;
|
|
55
|
+
}
|
|
3
56
|
//# sourceMappingURL=mcp-server.d.ts.map
|
package/dist/mcp-server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp-server.d.ts","sourceRoot":"","sources":["../src/mcp-server.ts"],"names":[],"mappings":""}
|
|
1
|
+
{"version":3,"file":"mcp-server.d.ts","sourceRoot":"","sources":["../src/mcp-server.ts"],"names":[],"mappings":";AAcA,OAAO,KAAK,EAaV,UAAU,EAEX,MAAM,YAAY,CAAC;AAsDpB,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,MAAM,CAAa;IAC3B,qFAAqF;IACrF,OAAO,CAAC,kBAAkB,CAAqB;IAE/C,sFAAsF;gBAC1E,MAAM,GAAE,UAAyB;IAY7C,OAAO,CAAC,aAAa;IA4brB;;;OAGG;YACW,gBAAgB;IA0J9B;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAIpB;;;;;;;OAOG;YACW,qBAAqB;YAiErB,oBAAoB;IAUlC,8DAA8D;YAChD,UAAU;IAuBxB;;;;;;OAMG;IACH,OAAO,CAAC,aAAa;IAUrB;;;;;;;OAOG;IACH,OAAO,CAAC,cAAc;IAyBhB,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA0B1B,uEAAuE;YACzD,wBAAwB;CAkBvC"}
|
package/dist/mcp-server.js
CHANGED
|
@@ -4,25 +4,60 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import { v4 as uuidv4 } from 'uuid';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
|
+
import { realpathSync } from 'fs';
|
|
7
8
|
import path from 'path';
|
|
8
|
-
import {
|
|
9
|
+
import { PROTOCOL_VERSION } from './types.js';
|
|
10
|
+
import { NtfyTransport, AttachmentExpiredError, AbortedWaitError } from './ntfy-transport.js';
|
|
9
11
|
import { loadConfig } from './config.js';
|
|
10
12
|
import { detectRepoContext } from './git-context.js';
|
|
11
13
|
import { performSetup, ensureClientRunning } from './setup.js';
|
|
14
|
+
import { SERVER_VERSION } from './version.js';
|
|
15
|
+
import { readPlanFile, PlanFileError } from './plan-file.js';
|
|
16
|
+
import { resolvePlanIdentity, prepareRevision } from './snapshot-store.js';
|
|
17
|
+
import { resolveBaseline, buildPlanDiff } from './plan-diff.js';
|
|
18
|
+
import { encodePayload, decodePayload, PayloadDecodeError } from './payload.js';
|
|
19
|
+
import { parseVerdict, normalizeResponseBody, ReviewResponseError } from './plan-review.js';
|
|
12
20
|
const TOOL_NAME = 'AskUserQuestion';
|
|
13
21
|
const NOTIFY_TOOL_NAME = 'Notify';
|
|
14
22
|
const SETUP_TOOL_NAME = 'setup';
|
|
23
|
+
const REVIEW_TOOL_NAME = 'ReviewPlan';
|
|
15
24
|
const SERVER_NAME = 'hitl-mcp-server';
|
|
16
|
-
|
|
25
|
+
/** Interval between progress notifications that keep a blocked MCP call alive. */
|
|
26
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
27
|
+
/**
|
|
28
|
+
* How long to keep listening for a second device's submission after a review
|
|
29
|
+
* has already been answered, so the loser is told rather than left hanging.
|
|
30
|
+
*/
|
|
31
|
+
const LATE_RESPONSE_WINDOW_MS = 45_000;
|
|
17
32
|
/** Directory where the compiled server JS lives (used for relative binary paths). */
|
|
18
33
|
const SERVER_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
-
|
|
34
|
+
/** Best-effort message for an unknown thrown value, for ack reasons and logs. */
|
|
35
|
+
function describeError(err) {
|
|
36
|
+
return err instanceof Error ? err.message : String(err);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The honest message for a genuine abort of `waitFor`/`waitForAnswer`: a real,
|
|
40
|
+
* recoverable event, not the same thing as ntfy being unreachable.
|
|
41
|
+
*
|
|
42
|
+
* A Stop and a host-side timeout are the two things that actually cancel the
|
|
43
|
+
* wait — this says so instead of leaving the agent staring at "was cancelled".
|
|
44
|
+
* `CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS=0` is offered as something to try, not
|
|
45
|
+
* as a stated cause: auto-background only ever detaches an already-running
|
|
46
|
+
* call from the foreground, it does not itself abort the request.
|
|
47
|
+
*/
|
|
48
|
+
const ABORT_REMEDIATION_MESSAGE = 'Wait cancelled before response; may be Stop or host timeout; ' +
|
|
49
|
+
'set CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS=0 in global settings and restart.';
|
|
50
|
+
export class HumanInTheLoopServer {
|
|
20
51
|
server;
|
|
21
52
|
transport;
|
|
22
|
-
|
|
23
|
-
|
|
53
|
+
config;
|
|
54
|
+
/** reviewIds still waiting on a human, so a graceful exit can release them (D-3). */
|
|
55
|
+
outstandingReviews = new Set();
|
|
56
|
+
/** `config` is injectable so a test can construct a server without a real ~/.hitl. */
|
|
57
|
+
constructor(config = loadConfig()) {
|
|
58
|
+
this.config = config;
|
|
24
59
|
this.server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
25
|
-
this.transport = new NtfyTransport(config);
|
|
60
|
+
this.transport = new NtfyTransport(this.config);
|
|
26
61
|
this.setupHandlers();
|
|
27
62
|
}
|
|
28
63
|
setupHandlers() {
|
|
@@ -169,12 +204,6 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
|
|
|
169
204
|
description: 'Whether to show an "Additional Context" text field for supplementary information',
|
|
170
205
|
default: true,
|
|
171
206
|
},
|
|
172
|
-
timeout: {
|
|
173
|
-
type: 'number',
|
|
174
|
-
description: 'Timeout in milliseconds (default: 3600000 = 1 hour)',
|
|
175
|
-
minimum: 1000,
|
|
176
|
-
maximum: 86400000,
|
|
177
|
-
},
|
|
178
207
|
},
|
|
179
208
|
required: ['context'],
|
|
180
209
|
},
|
|
@@ -216,6 +245,43 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
|
|
|
216
245
|
required: ['title', 'body'],
|
|
217
246
|
},
|
|
218
247
|
},
|
|
248
|
+
{
|
|
249
|
+
name: REVIEW_TOOL_NAME,
|
|
250
|
+
description: `Get line-anchored human review of an implementation plan you have written to a markdown file.
|
|
251
|
+
|
|
252
|
+
The plan opens as a two-pane review window on every device the user has subscribed — phone, laptop, desktop — so they can review it wherever they are. They select line ranges, attach comments to them, and return a verdict. This call blocks until they submit.
|
|
253
|
+
|
|
254
|
+
Use this instead of pasting a plan into AskUserQuestion whenever you want feedback on specific lines rather than a yes/no. Typical moments: after drafting an implementation plan, a migration sequence, an architecture proposal, or a refactor outline.
|
|
255
|
+
|
|
256
|
+
IMPORTANT — do not modify the file while this call is blocked. The returned snapshotHash identifies the exact content the human reviewed; if you rewrite the file mid-review, their approval applies to text that no longer exists.
|
|
257
|
+
|
|
258
|
+
Revisions: call it again with the same file after making the requested changes. The user sees a diff against what they reviewed last time rather than the whole plan again, and the revision number increments.
|
|
259
|
+
|
|
260
|
+
Returns JSON: { success, timestamp, respondedFrom, verdict, overallFeedback, inlineComments[], revision, isNewPlan, snapshotHash }.
|
|
261
|
+
- verdict is one of: approved | changes_requested | rejected | skipped | cancelled
|
|
262
|
+
- inlineComments are { path, startLine, endLine, side, comment }, stably sorted, with line numbers in the source-line space of the file you passed
|
|
263
|
+
- changes_requested and rejected always carry either overallFeedback or at least one inline comment
|
|
264
|
+
|
|
265
|
+
Blocking past 60 seconds requires the calling MCP host to opt into resetTimeoutOnProgress; this server emits progress heartbeats but cannot enforce the host's timeout.`,
|
|
266
|
+
inputSchema: {
|
|
267
|
+
type: 'object',
|
|
268
|
+
properties: {
|
|
269
|
+
filePath: {
|
|
270
|
+
type: 'string',
|
|
271
|
+
description: 'Absolute or cwd-relative path to the markdown plan file (.md or .markdown, up to 1 MB).',
|
|
272
|
+
},
|
|
273
|
+
context: {
|
|
274
|
+
type: 'string',
|
|
275
|
+
description: 'Brief description of what project and work you are doing. This helps the human understand the situation across devices.',
|
|
276
|
+
},
|
|
277
|
+
summary: {
|
|
278
|
+
type: 'string',
|
|
279
|
+
description: 'Optional short prose framing shown above the document — what changed since last time, or what you most want feedback on.',
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
required: ['filePath', 'context'],
|
|
283
|
+
},
|
|
284
|
+
},
|
|
219
285
|
],
|
|
220
286
|
}));
|
|
221
287
|
this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
@@ -238,7 +304,7 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
|
|
|
238
304
|
throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters: title and body');
|
|
239
305
|
}
|
|
240
306
|
try {
|
|
241
|
-
|
|
307
|
+
this.requireClient();
|
|
242
308
|
const notification = {
|
|
243
309
|
type: 'notification',
|
|
244
310
|
messageId: uuidv4(),
|
|
@@ -256,6 +322,9 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
|
|
|
256
322
|
throw new McpError(ErrorCode.InternalError, `Failed to send notification: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
257
323
|
}
|
|
258
324
|
}
|
|
325
|
+
if (request.params.name === REVIEW_TOOL_NAME) {
|
|
326
|
+
return await this.handleReviewPlan(request.params.arguments, extra);
|
|
327
|
+
}
|
|
259
328
|
if (request.params.name !== TOOL_NAME) {
|
|
260
329
|
throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${request.params.name}`);
|
|
261
330
|
}
|
|
@@ -269,8 +338,9 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
|
|
|
269
338
|
throw new McpError(ErrorCode.InvalidParams, 'Provide either question+options for a single question, or a questions array for batch mode');
|
|
270
339
|
}
|
|
271
340
|
try {
|
|
272
|
-
// Auto-launch client if not running
|
|
273
|
-
|
|
341
|
+
// Auto-launch client if not running. Must precede publish: with no
|
|
342
|
+
// timeout, publishing to a topic nobody reads blocks forever (A-10).
|
|
343
|
+
this.requireClient();
|
|
274
344
|
const repo = detectRepoContext();
|
|
275
345
|
const mapOption = (opt) => ({
|
|
276
346
|
label: opt.label || opt.value || '',
|
|
@@ -306,44 +376,27 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
|
|
|
306
376
|
: args.options.map(mapOption),
|
|
307
377
|
allowMultiple: hasBatchQuestions ? false : args.allowMultiple !== false,
|
|
308
378
|
allowOther: hasBatchQuestions ? true : args.allowOther !== false,
|
|
309
|
-
timeout: args.timeout || 3600000,
|
|
310
379
|
questions: batchQuestions,
|
|
311
380
|
};
|
|
312
381
|
console.error(`Publishing question ${questionMsg.messageId} to ntfy...`);
|
|
313
382
|
await this.transport.publish(questionMsg);
|
|
314
383
|
console.error('Question published. Waiting for answer...');
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
let progressCount = 0;
|
|
321
|
-
if (progressToken !== undefined) {
|
|
322
|
-
heartbeatTimer = setInterval(async () => {
|
|
323
|
-
progressCount++;
|
|
324
|
-
try {
|
|
325
|
-
await extra.sendNotification({
|
|
326
|
-
method: 'notifications/progress',
|
|
327
|
-
params: {
|
|
328
|
-
progressToken,
|
|
329
|
-
progress: progressCount,
|
|
330
|
-
total: 0,
|
|
331
|
-
message: 'Waiting for human response...',
|
|
332
|
-
},
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
catch {
|
|
336
|
-
// Client may have disconnected; ignore
|
|
337
|
-
}
|
|
338
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
339
|
-
}
|
|
384
|
+
this.transport.pending.record({
|
|
385
|
+
kind: 'question',
|
|
386
|
+
id: questionMsg.messageId,
|
|
387
|
+
createdAt: Date.now(),
|
|
388
|
+
});
|
|
340
389
|
let answer;
|
|
390
|
+
const stopHeartbeat = this.startHeartbeat(extra);
|
|
341
391
|
try {
|
|
342
|
-
answer = await this.transport.waitForAnswer(questionMsg.messageId,
|
|
392
|
+
answer = await this.transport.waitForAnswer(questionMsg.messageId, extra?.signal);
|
|
343
393
|
}
|
|
344
394
|
finally {
|
|
345
|
-
|
|
346
|
-
|
|
395
|
+
// One finally for both: a host cancellation must release the SSE
|
|
396
|
+
// connection and the timer together, or every stop/retry cycle leaks
|
|
397
|
+
// one of each for the life of the process (D-9).
|
|
398
|
+
stopHeartbeat();
|
|
399
|
+
this.transport.pending.clear(questionMsg.messageId);
|
|
347
400
|
}
|
|
348
401
|
console.error(`Answer received from ${answer.respondedFrom}`);
|
|
349
402
|
// Strip (RECOMMENDED) markers from values
|
|
@@ -400,40 +453,332 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
|
|
|
400
453
|
}
|
|
401
454
|
catch (error) {
|
|
402
455
|
console.error('Dialog error:', error);
|
|
403
|
-
if (error instanceof
|
|
404
|
-
|
|
405
|
-
content: [
|
|
406
|
-
{
|
|
407
|
-
type: 'text',
|
|
408
|
-
text: JSON.stringify({
|
|
409
|
-
success: false,
|
|
410
|
-
error: 'timeout',
|
|
411
|
-
message: 'The user did not respond within the timeout period',
|
|
412
|
-
}, null, 2),
|
|
413
|
-
},
|
|
414
|
-
],
|
|
415
|
-
};
|
|
456
|
+
if (error instanceof AbortedWaitError) {
|
|
457
|
+
throw new McpError(ErrorCode.InternalError, ABORT_REMEDIATION_MESSAGE);
|
|
416
458
|
}
|
|
417
459
|
throw new McpError(ErrorCode.InternalError, `Failed to get human response: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
418
460
|
}
|
|
419
461
|
});
|
|
420
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* ReviewPlan: snapshot a markdown plan, publish it for line-anchored review,
|
|
465
|
+
* and block until a human submits a verdict.
|
|
466
|
+
*/
|
|
467
|
+
async handleReviewPlan(args, extra) {
|
|
468
|
+
if (typeof args?.filePath !== 'string' || args.filePath.trim() === '') {
|
|
469
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: filePath');
|
|
470
|
+
}
|
|
471
|
+
if (typeof args?.context !== 'string' || args.context.trim() === '') {
|
|
472
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: context');
|
|
473
|
+
}
|
|
474
|
+
// Validate and read before anything else: a rejected path must never reach
|
|
475
|
+
// the snapshot store, ntfy, or the human's screen.
|
|
476
|
+
let plan;
|
|
477
|
+
try {
|
|
478
|
+
plan = readPlanFile(args.filePath);
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
if (err instanceof PlanFileError) {
|
|
482
|
+
throw new McpError(ErrorCode.InvalidParams, err.message);
|
|
483
|
+
}
|
|
484
|
+
throw err;
|
|
485
|
+
}
|
|
486
|
+
this.requireClient();
|
|
487
|
+
const identity = resolvePlanIdentity(plan.resolvedPath);
|
|
488
|
+
let recorded;
|
|
489
|
+
try {
|
|
490
|
+
recorded = prepareRevision(identity, plan.content);
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
// The store retries what is transient; anything reaching here is a real
|
|
494
|
+
// filesystem problem, and a raw EPERM stack is not something an agent
|
|
495
|
+
// can act on.
|
|
496
|
+
throw new McpError(ErrorCode.InternalError, `Could not record a snapshot of ${identity.displayPath}: ${describeError(err)}. ` +
|
|
497
|
+
`The plan review needs to write under ${path.dirname(identity.dir)}.`);
|
|
498
|
+
}
|
|
499
|
+
const snapshotHash = `sha256:${recorded.digest}`;
|
|
500
|
+
const diff = buildPlanDiff(identity.displayPath, plan.content, resolveBaseline(identity, recorded));
|
|
501
|
+
// Reuse the reviewId of an identical review this or a previous process was
|
|
502
|
+
// still waiting on. A window may still be open for it on the human's
|
|
503
|
+
// device, and its response then resolves this call instead of asking
|
|
504
|
+
// again (D-8).
|
|
505
|
+
const resumable = this.transport.pending.findResumableReview(identity.planId, snapshotHash);
|
|
506
|
+
const reviewId = resumable?.id ?? uuidv4();
|
|
507
|
+
const body = { content: plan.content, diff };
|
|
508
|
+
const encoded = encodePayload(body, this.config.encryptionKey);
|
|
509
|
+
const reviewMsg = {
|
|
510
|
+
type: 'plan_review',
|
|
511
|
+
messageId: reviewId,
|
|
512
|
+
timestamp: Date.now(),
|
|
513
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
514
|
+
repo: detectRepoContext(path.dirname(plan.resolvedPath)),
|
|
515
|
+
context: args.context,
|
|
516
|
+
summary: typeof args.summary === 'string' ? args.summary : '',
|
|
517
|
+
displayPath: identity.displayPath,
|
|
518
|
+
planId: identity.planId,
|
|
519
|
+
revision: recorded.revision,
|
|
520
|
+
isNewPlan: recorded.isNewPlan,
|
|
521
|
+
snapshotHash,
|
|
522
|
+
body: encoded.ref,
|
|
523
|
+
};
|
|
524
|
+
this.transport.pending.record({
|
|
525
|
+
kind: 'plan_review',
|
|
526
|
+
id: reviewId,
|
|
527
|
+
planId: identity.planId,
|
|
528
|
+
snapshotHash,
|
|
529
|
+
createdAt: Date.now(),
|
|
530
|
+
});
|
|
531
|
+
this.outstandingReviews.add(reviewId);
|
|
532
|
+
console.error(`Publishing plan_review ${reviewId} (revision ${recorded.revision}, ` +
|
|
533
|
+
`${encoded.ref.kind} payload, ${encoded.ref.contentLength} bytes) to ntfy...`);
|
|
534
|
+
const stopHeartbeat = this.startHeartbeat(extra);
|
|
535
|
+
// Watch for a second device submitting after the first one already won.
|
|
536
|
+
// Registered before the wait so there is no window in which a late
|
|
537
|
+
// response goes unnoticed; `watch` only sees what no waiter consumed, so
|
|
538
|
+
// the winning response never reaches it (D-5).
|
|
539
|
+
const stopWatchingLateResponses = this.transport.watch(`late_response:${reviewId}`, (msg) => msg.type === 'plan_review_response' && msg.reviewId === reviewId, (received) => {
|
|
540
|
+
const late = received.msg;
|
|
541
|
+
console.error(`Review ${reviewId} already resolved; telling ${late.respondedFrom} it was lost.`);
|
|
542
|
+
void this.publishAck(reviewId, late.messageId, 'lost', 'This review had already been submitted from another device.');
|
|
543
|
+
});
|
|
544
|
+
try {
|
|
545
|
+
await this.transport.publishPlan(reviewMsg, encoded.ref.kind === 'attachment' ? encoded.cipher : undefined);
|
|
546
|
+
// The revision is out the door, so it may now become the baseline the
|
|
547
|
+
// next review diffs against (M6). A failure here only leaves the next
|
|
548
|
+
// diff rebased on the older revision — worth saying out loud, not worth
|
|
549
|
+
// abandoning a review the human is already looking at.
|
|
550
|
+
try {
|
|
551
|
+
recorded.commit();
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
console.error(`Published review ${reviewId} but could not update the snapshot pointer for ` +
|
|
555
|
+
`${identity.displayPath}: ${describeError(err)}. The next review will diff against ` +
|
|
556
|
+
`revision ${recorded.previous?.revision ?? 0}.`);
|
|
557
|
+
}
|
|
558
|
+
const { msg: response, attachment } = await this.transport.waitFor(`plan_review_response:${reviewId}`, (msg) => msg.type === 'plan_review_response' && msg.reviewId === reviewId, extra?.signal);
|
|
559
|
+
const result = await this.consumeReviewResponse(reviewMsg, response, attachment);
|
|
560
|
+
// Stay attached briefly so a submit that lost the race still gets told,
|
|
561
|
+
// rather than the losing client sitting on a 30 s ack timeout.
|
|
562
|
+
this.releaseLater(stopWatchingLateResponses);
|
|
563
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
stopWatchingLateResponses();
|
|
567
|
+
if (error instanceof McpError)
|
|
568
|
+
throw error;
|
|
569
|
+
console.error('ReviewPlan error:', error);
|
|
570
|
+
if (error instanceof AbortedWaitError) {
|
|
571
|
+
throw new McpError(ErrorCode.InternalError, ABORT_REMEDIATION_MESSAGE);
|
|
572
|
+
}
|
|
573
|
+
throw new McpError(ErrorCode.InternalError, `Failed to get plan review: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
574
|
+
}
|
|
575
|
+
finally {
|
|
576
|
+
// Same finally for the timer and the wait registration: a host
|
|
577
|
+
// cancellation must release both, not leak one per stop/retry cycle (D-9).
|
|
578
|
+
stopHeartbeat();
|
|
579
|
+
this.outstandingReviews.delete(reviewId);
|
|
580
|
+
this.transport.pending.clear(reviewId);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Drop a subscription hold after the late-response window.
|
|
585
|
+
*
|
|
586
|
+
* The timer is unref'd so it can never be the reason the process stays alive.
|
|
587
|
+
*/
|
|
588
|
+
releaseLater(release) {
|
|
589
|
+
setTimeout(release, LATE_RESPONSE_WINDOW_MS).unref?.();
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Download, decode and validate a submitted review, then acknowledge it.
|
|
593
|
+
*
|
|
594
|
+
* The ack is what lets the client distinguish "the server read this" from "I
|
|
595
|
+
* clicked submit": the response attachment expires after 3 h while the
|
|
596
|
+
* message itself survives 12 h, so a submit can be accepted at click time and
|
|
597
|
+
* still never arrive (C-12).
|
|
598
|
+
*/
|
|
599
|
+
async consumeReviewResponse(reviewMsg, response, attachment) {
|
|
600
|
+
let verdict;
|
|
601
|
+
let body;
|
|
602
|
+
try {
|
|
603
|
+
// Inside the try so a client that sends nonsense is acknowledged as
|
|
604
|
+
// lost rather than leaving the human's window claiming success.
|
|
605
|
+
verdict = parseVerdict(response.verdict);
|
|
606
|
+
// The human must have reviewed the content we published. A mismatch is a
|
|
607
|
+
// stale window or a broken client, and accepting it would approve this
|
|
608
|
+
// revision on the strength of a review of a different one (A-8/M5).
|
|
609
|
+
if (response.snapshotHash && response.snapshotHash !== reviewMsg.snapshotHash) {
|
|
610
|
+
throw new ReviewResponseError(`Response is for snapshot ${response.snapshotHash}, but this review published ` +
|
|
611
|
+
`${reviewMsg.snapshotHash}.`);
|
|
612
|
+
}
|
|
613
|
+
const cipher = response.body?.kind === 'attachment'
|
|
614
|
+
? await this.downloadResponseBody(attachment)
|
|
615
|
+
: response.body?.data ?? '';
|
|
616
|
+
const decoded = decodePayload(cipher, this.config.encryptionKey, response.body?.contentHash ?? '');
|
|
617
|
+
body = normalizeResponseBody(verdict, decoded, reviewMsg.displayPath);
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
await this.publishAck(reviewMsg.messageId, response.messageId, 'lost', describeError(err));
|
|
621
|
+
if (err instanceof AttachmentExpiredError) {
|
|
622
|
+
throw new McpError(ErrorCode.InternalError, `The review was submitted but its payload had already expired on ntfy (attachments live 3 h). ` +
|
|
623
|
+
`Ask for the review again.`);
|
|
624
|
+
}
|
|
625
|
+
if (err instanceof PayloadDecodeError || err instanceof ReviewResponseError) {
|
|
626
|
+
throw new McpError(ErrorCode.InternalError, `Unusable review response: ${err.message}`);
|
|
627
|
+
}
|
|
628
|
+
throw err;
|
|
629
|
+
}
|
|
630
|
+
await this.publishAck(reviewMsg.messageId, response.messageId, 'received');
|
|
631
|
+
return {
|
|
632
|
+
success: true,
|
|
633
|
+
timestamp: response.timestamp,
|
|
634
|
+
respondedFrom: response.respondedFrom,
|
|
635
|
+
verdict,
|
|
636
|
+
overallFeedback: body.overallFeedback,
|
|
637
|
+
inlineComments: body.inlineComments,
|
|
638
|
+
revision: reviewMsg.revision,
|
|
639
|
+
isNewPlan: reviewMsg.isNewPlan,
|
|
640
|
+
// Checked against what we published above, so this is the content the
|
|
641
|
+
// human actually reviewed rather than whatever the client claimed (A-8).
|
|
642
|
+
snapshotHash: reviewMsg.snapshotHash,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
async downloadResponseBody(attachment) {
|
|
646
|
+
if (!attachment) {
|
|
647
|
+
throw new McpError(ErrorCode.InternalError, 'Review response claims an attachment payload but the message carried no attachment URL.');
|
|
648
|
+
}
|
|
649
|
+
return await this.transport.downloadAttachment(attachment);
|
|
650
|
+
}
|
|
651
|
+
/** Tell the client whether its submission actually landed. */
|
|
652
|
+
async publishAck(reviewId, responseId, status, reason) {
|
|
653
|
+
const ack = {
|
|
654
|
+
type: 'plan_review_ack',
|
|
655
|
+
messageId: uuidv4(),
|
|
656
|
+
timestamp: Date.now(),
|
|
657
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
658
|
+
reviewId,
|
|
659
|
+
responseId,
|
|
660
|
+
status,
|
|
661
|
+
reason,
|
|
662
|
+
};
|
|
663
|
+
try {
|
|
664
|
+
await this.transport.publishPlan(ack);
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
console.error(`Could not acknowledge review ${reviewId}: ${err}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Fail before publishing when no client can be found or launched.
|
|
672
|
+
*
|
|
673
|
+
* Without a timeout to bound it, publishing to a topic nobody is subscribed
|
|
674
|
+
* to is a permanent silent hang — the agent blocks and the human never sees a
|
|
675
|
+
* window to explain why (A-10).
|
|
676
|
+
*/
|
|
677
|
+
requireClient() {
|
|
678
|
+
const result = ensureClientRunning(SERVER_DIR);
|
|
679
|
+
if (!result.ok) {
|
|
680
|
+
throw new McpError(ErrorCode.InternalError, `No HITL client available, so nobody would see this. ${result.reason ?? ''}`.trim());
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Start emitting progress notifications, returning the stop function.
|
|
685
|
+
*
|
|
686
|
+
* Whether these actually keep the call alive is the calling host's decision:
|
|
687
|
+
* the MCP SDK's DEFAULT_REQUEST_TIMEOUT_MSEC is 60 s and
|
|
688
|
+
* `resetTimeoutOnProgress` defaults to false, so a host that does not opt in
|
|
689
|
+
* will time the call out regardless of what we send (D-2).
|
|
690
|
+
*/
|
|
691
|
+
startHeartbeat(extra) {
|
|
692
|
+
const progressToken = extra?._meta?.progressToken;
|
|
693
|
+
if (progressToken === undefined)
|
|
694
|
+
return () => { };
|
|
695
|
+
let progressCount = 0;
|
|
696
|
+
const timer = setInterval(async () => {
|
|
697
|
+
progressCount++;
|
|
698
|
+
try {
|
|
699
|
+
await extra.sendNotification({
|
|
700
|
+
method: 'notifications/progress',
|
|
701
|
+
params: {
|
|
702
|
+
progressToken,
|
|
703
|
+
progress: progressCount,
|
|
704
|
+
total: 0,
|
|
705
|
+
message: 'Waiting for human response...',
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// Client may have disconnected; ignore
|
|
711
|
+
}
|
|
712
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
713
|
+
return () => clearInterval(timer);
|
|
714
|
+
}
|
|
421
715
|
async run() {
|
|
422
716
|
const stdioTransport = new StdioServerTransport();
|
|
423
717
|
await this.server.connect(stdioTransport);
|
|
424
718
|
console.error(`${SERVER_NAME} v${SERVER_VERSION} running on stdio (ntfy-backed)`);
|
|
425
|
-
|
|
719
|
+
let shuttingDown = false;
|
|
720
|
+
const shutdown = async () => {
|
|
721
|
+
if (shuttingDown)
|
|
722
|
+
return;
|
|
723
|
+
shuttingDown = true;
|
|
426
724
|
console.error('Shutting down...');
|
|
725
|
+
// Tell open review windows their agent is gone before dropping the
|
|
726
|
+
// connection, so they show "agent exited" and keep the typed comments
|
|
727
|
+
// rather than waiting on a process that no longer exists (D-3).
|
|
728
|
+
// A hard kill (SIGKILL/OOM) cannot be caught here by design.
|
|
729
|
+
await this.cancelOutstandingReviews('agent_exited');
|
|
427
730
|
this.transport.close();
|
|
428
731
|
process.exit(0);
|
|
429
732
|
};
|
|
430
|
-
process.on('SIGINT', shutdown);
|
|
431
|
-
process.on('SIGTERM', shutdown);
|
|
733
|
+
process.on('SIGINT', () => void shutdown());
|
|
734
|
+
process.on('SIGTERM', () => void shutdown());
|
|
432
735
|
}
|
|
736
|
+
/** Publish cancel_review for every review still waiting on a human. */
|
|
737
|
+
async cancelOutstandingReviews(reason) {
|
|
738
|
+
for (const reviewId of this.outstandingReviews) {
|
|
739
|
+
const cancel = {
|
|
740
|
+
type: 'cancel_review',
|
|
741
|
+
messageId: uuidv4(),
|
|
742
|
+
timestamp: Date.now(),
|
|
743
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
744
|
+
reviewId,
|
|
745
|
+
reason,
|
|
746
|
+
};
|
|
747
|
+
try {
|
|
748
|
+
await this.transport.publishPlan(cancel);
|
|
749
|
+
}
|
|
750
|
+
catch (err) {
|
|
751
|
+
console.error(`Could not cancel review ${reviewId}: ${err}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
this.outstandingReviews.clear();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* True when this file is what node was asked to run, rather than something
|
|
759
|
+
* another module imported.
|
|
760
|
+
*
|
|
761
|
+
* Compared through realpath because npm installs the `bin` entry as a symlink:
|
|
762
|
+
* `process.argv[1]` is then the link in `node_modules/.bin`, not this file. If
|
|
763
|
+
* the comparison cannot be made at all we boot — failing to start a server
|
|
764
|
+
* someone asked for is far worse than starting one nobody wanted.
|
|
765
|
+
*/
|
|
766
|
+
function isDirectlyExecuted() {
|
|
767
|
+
const entry = process.argv[1];
|
|
768
|
+
if (entry === undefined)
|
|
769
|
+
return false;
|
|
770
|
+
try {
|
|
771
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (isDirectlyExecuted()) {
|
|
778
|
+
const server = new HumanInTheLoopServer();
|
|
779
|
+
server.run().catch((error) => {
|
|
780
|
+
console.error('Server error:', error);
|
|
781
|
+
process.exit(1);
|
|
782
|
+
});
|
|
433
783
|
}
|
|
434
|
-
const server = new HumanInTheLoopServer();
|
|
435
|
-
server.run().catch((error) => {
|
|
436
|
-
console.error('Server error:', error);
|
|
437
|
-
process.exit(1);
|
|
438
|
-
});
|
|
439
784
|
//# sourceMappingURL=mcp-server.js.map
|