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