ue-mcp 1.3.3 → 1.3.4
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/dialog-guard.d.ts +287 -0
- package/dist/dialog-guard.js +700 -0
- package/dist/dialog-guard.js.map +1 -0
- package/dist/editor-control.d.ts +77 -3
- package/dist/editor-control.js +163 -108
- package/dist/editor-control.js.map +1 -1
- package/dist/epic-cache.d.ts +12 -0
- package/dist/epic-cache.js +13 -0
- package/dist/epic-cache.js.map +1 -1
- package/dist/flow/guarded-bridge.d.ts +44 -10
- package/dist/flow/guarded-bridge.js +88 -20
- package/dist/flow/guarded-bridge.js.map +1 -1
- package/dist/flow/http-server.js +47 -4
- package/dist/flow/http-server.js.map +1 -1
- package/dist/flow/task-factory.js +41 -0
- package/dist/flow/task-factory.js.map +1 -1
- package/dist/flow/task-guards.js +17 -1
- package/dist/flow/task-guards.js.map +1 -1
- package/dist/index.js +191 -68
- package/dist/index.js.map +1 -1
- package/dist/locking.js +15 -0
- package/dist/locking.js.map +1 -1
- package/dist/offline.js +11 -2
- package/dist/offline.js.map +1 -1
- package/dist/session.js +8 -1
- package/dist/session.js.map +1 -1
- package/dist/tool-counts.json +2 -2
- package/dist/tools/editor.js +27 -5
- package/dist/tools/editor.js.map +1 -1
- package/package.json +2 -2
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BridgeProtocolTests.cpp +1 -1
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SequencerScrubTests.cpp +1 -1
- package/plugin/ue_mcp_bridge/UE_MCP_Bridge.uplugin +1 -1
- package/dist/dialog-gate.d.ts +0 -73
- package/dist/dialog-gate.js +0 -197
- package/dist/dialog-gate.js.map +0 -1
|
@@ -3,6 +3,38 @@ import { stripEditorTarget } from "../types.js";
|
|
|
3
3
|
import { prepareCall, finishCall } from "../call-pipeline.js";
|
|
4
4
|
import { liftRollback } from "./rollback.js";
|
|
5
5
|
import { applyHandlerOutcome } from "./handler-outcome.js";
|
|
6
|
+
import { DialogGuard, ensureGuard } from "../dialog-guard.js";
|
|
7
|
+
/**
|
|
8
|
+
* Refuse a flow step while a modal is up, through the one guard.
|
|
9
|
+
*
|
|
10
|
+
* The bridge-backed steps are covered at the bridge boundary. An in-process
|
|
11
|
+
* handler never goes near it, so without this the gate simply was not on that
|
|
12
|
+
* route: a modal raised mid-run was missed by every remaining step.
|
|
13
|
+
*
|
|
14
|
+
* Returns the refusal to report, or null to run the step.
|
|
15
|
+
*/
|
|
16
|
+
async function refuseStepIfBlocked(ctx, taskName, options) {
|
|
17
|
+
const session = ctx.session;
|
|
18
|
+
if (!session)
|
|
19
|
+
return null;
|
|
20
|
+
// The micro gateway arrives as one task carrying the real category and
|
|
21
|
+
// method in its options. Asking the allowlist about the wrapper refused
|
|
22
|
+
// respond_to_dialog, which is the single call that clears the dialog, so the
|
|
23
|
+
// gateway could never escape one.
|
|
24
|
+
const subject = taskName === "tools.call"
|
|
25
|
+
? `${String(options.category ?? "")}.${String(options.method ?? "")}`
|
|
26
|
+
: taskName;
|
|
27
|
+
if (DialogGuard.actionAllowed(subject))
|
|
28
|
+
return null;
|
|
29
|
+
const guard = await ensureGuard(session);
|
|
30
|
+
const decision = await guard.check(subject, "action");
|
|
31
|
+
return decision.allow ? null : (decision.refusal ?? null);
|
|
32
|
+
}
|
|
33
|
+
/** The error a refused step reports, worded by the refusal itself. */
|
|
34
|
+
function refusalError(taskName, refusal) {
|
|
35
|
+
const said = typeof refusal.error === "string" ? refusal.error : undefined;
|
|
36
|
+
return new Error(said ?? `'${taskName}' was refused: a modal dialog is blocking the editor.`);
|
|
37
|
+
}
|
|
6
38
|
/**
|
|
7
39
|
* Create a TaskConstructor for a bridge-delegation action.
|
|
8
40
|
* The bridge method (and optional param mapper) are closed over in the class,
|
|
@@ -84,6 +116,15 @@ export function handlerTaskClass(name, fn, prep) {
|
|
|
84
116
|
const ctx = pipeline.timeoutMs === undefined
|
|
85
117
|
? this.ctx
|
|
86
118
|
: { ...this.ctx, callTimeoutMs: pipeline.timeoutMs };
|
|
119
|
+
// A flow is a minutes-long run, and a modal can appear at any point in
|
|
120
|
+
// it. The tool route checks once, before flow.run starts; without this,
|
|
121
|
+
// a dialog raised at step 3 was missed by every later step, which
|
|
122
|
+
// includes the editor lifecycle handlers. Checking per step is what
|
|
123
|
+
// makes "any process, at any point" true of flows as well.
|
|
124
|
+
const refusal = await refuseStepIfBlocked(this.ctx, name, this.options);
|
|
125
|
+
if (refusal) {
|
|
126
|
+
return { success: false, data: refusal, error: refusalError(name, refusal) };
|
|
127
|
+
}
|
|
87
128
|
const answered = await fn(ctx, pipeline.params);
|
|
88
129
|
const data = finishCall(answered, pipeline);
|
|
89
130
|
// Same defect as the bridge class had, from the same hardcoded `true`:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"task-factory.js","sourceRoot":"","sources":["../../src/flow/task-factory.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAwB,MAAM,qBAAqB,CAAC;AAEpF,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"task-factory.js","sourceRoot":"","sources":["../../src/flow/task-factory.ts"],"names":[],"mappings":"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"}
|
package/dist/flow/task-guards.js
CHANGED
|
@@ -25,6 +25,8 @@ import { discoverTaskGuards as discoverFlowkitGuards } from "@db-lyon/flowkit/gu
|
|
|
25
25
|
import { writeScope } from "./guard.js";
|
|
26
26
|
import { McpError, ErrorCode } from "../errors.js";
|
|
27
27
|
import { debug, info } from "../log.js";
|
|
28
|
+
import { withoutDialogActuation } from "../dialog-guard.js";
|
|
29
|
+
import { DialogGatedBridge } from "./guarded-bridge.js";
|
|
28
30
|
/** Adapts flowkit's logger to this server's `guard`-component logging. */
|
|
29
31
|
const GUARD_LOGGER = {
|
|
30
32
|
debug: (...args) => debug("guard", args.map(String).join(" ")),
|
|
@@ -57,8 +59,22 @@ export function discoverTaskGuards(registry, ctx, rawBridge) {
|
|
|
57
59
|
// bridge happened to be built first. cc.bridge is the RAW bridge of the
|
|
58
60
|
// session serving this call, so a guard can neither recurse through the
|
|
59
61
|
// pipeline nor act on another project's editor.
|
|
62
|
+
// The raw bridge, gated. Raw is deliberate and stays: a guard task must
|
|
63
|
+
// not re-enter the flow pipeline that is running it. That is a statement
|
|
64
|
+
// about the registry, not about dialogs, and taking it to mean both left
|
|
65
|
+
// guard tasks as the one route that reached a parked game thread
|
|
66
|
+
// unrefused, where every call hung until it timed out. DialogGatedBridge
|
|
67
|
+
// puts the gate back with no pipeline attached, and
|
|
68
|
+
// withoutDialogActuation stays on top because set_dialog_policy is
|
|
69
|
+
// modal-safe in the plugin, so a guard task could otherwise arm a policy
|
|
70
|
+
// that answers the modal already on screen.
|
|
60
71
|
contextFor: (cc) => cc.session
|
|
61
|
-
? {
|
|
72
|
+
? {
|
|
73
|
+
...ctx,
|
|
74
|
+
bridge: withoutDialogActuation(cc.session, new DialogGatedBridge(cc.bridge, cc.session)),
|
|
75
|
+
project: cc.session.project,
|
|
76
|
+
session: cc.session,
|
|
77
|
+
}
|
|
62
78
|
: { ...ctx, bridge: rawBridge },
|
|
63
79
|
optionsFor: (cc, result) => ({
|
|
64
80
|
method: cc.method,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"task-guards.js","sourceRoot":"","sources":["../../src/flow/task-guards.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9B,OAAO,EAAE,kBAAkB,IAAI,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAMrF,OAAO,EAAE,UAAU,EAAgE,MAAM,YAAY,CAAC;AACtG,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"task-guards.js","sourceRoot":"","sources":["../../src/flow/task-guards.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { resolveContextStrategy, applyLeanContext, buildMicroGateway } from "./l
|
|
|
9
9
|
import { routeEditorCall, effectiveTaskName, refuseUntargetedInRegistry, editorAttribution, } from "./editor-gate.js";
|
|
10
10
|
import { injectEditorTarget, removeEditorTarget, injectMigrateTarget, removeMigrateTarget, stripEditorTarget, sessionContext, EDITOR_TARGET_PARAM, } from "./types.js";
|
|
11
11
|
import { McpError, ErrorCode } from "./errors.js";
|
|
12
|
-
import {
|
|
12
|
+
import { DialogGuard, guardFor, existingGuard, isDialogRefusal, stampBlockedEditor, } from "./dialog-guard.js";
|
|
13
13
|
import { resolveDialogMode, clientAdvertisesElicitation } from "./editor-control.js";
|
|
14
14
|
import { info, warn, debug } from "./log.js";
|
|
15
15
|
import { startVersionCheck, consumeUpgradeNotice } from "./version-check.js";
|
|
@@ -29,7 +29,8 @@ import { ALL_TOOLS, setLiveToolGraph } from "./tools.js";
|
|
|
29
29
|
import { nearestActions } from "./action-schema.js";
|
|
30
30
|
import { enrichToolsWithEpicCatalog } from "./epic-enrich.js";
|
|
31
31
|
import { checkPluginFreshness } from "./plugin-freshness.js";
|
|
32
|
-
import {
|
|
32
|
+
import { readEngineSnapshot } from "./engine-observer.js";
|
|
33
|
+
import { saveCatalogCache, loadCatalogCache, loadBakedCatalog, catalogOrRefusal, } from "./epic-cache.js";
|
|
33
34
|
import { baseGraphFor, unionSurface, unionKnowledge, explainMissingAction, } from "./session-surface.js";
|
|
34
35
|
function withUpgradeNotice(content) {
|
|
35
36
|
const notice = consumeUpgradeNotice();
|
|
@@ -162,6 +163,13 @@ async function main() {
|
|
|
162
163
|
const surfaces = [];
|
|
163
164
|
const perSession = new Map();
|
|
164
165
|
for (const session of sessions.list()) {
|
|
166
|
+
// BEFORE the surface build, which calls epic_list_toolsets on the editor.
|
|
167
|
+
// Creating guards afterwards meant the first call this server ever makes
|
|
168
|
+
// was the one call with no guard behind it: a modal refused it, the
|
|
169
|
+
// refusal has no toolsets, and the surface silently fell back to a cached
|
|
170
|
+
// one with nothing latched. startWatching is idempotent, so the later
|
|
171
|
+
// pass over sessions.list() stays harmless.
|
|
172
|
+
dialogGuardFor(session);
|
|
165
173
|
const load = await buildSessionLoad(session, pkg.version, sessions.size > 1);
|
|
166
174
|
perSession.set(session, load);
|
|
167
175
|
surfaces.push(load.surface);
|
|
@@ -311,6 +319,35 @@ async function main() {
|
|
|
311
319
|
// registry starts empty (pass-through) and is populated once the task
|
|
312
320
|
// registry exists, below.
|
|
313
321
|
const guardedBridge = primary.guarded;
|
|
322
|
+
/**
|
|
323
|
+
* The dialog guard for one editor, created on first use and kept.
|
|
324
|
+
*
|
|
325
|
+
* Watching starts with the guard, so a modal raised while the session sits
|
|
326
|
+
* idle is known before anything is called: the plugin refreshes its status
|
|
327
|
+
* file from the modal-loop tick, which is the tick that keeps running while
|
|
328
|
+
* the game thread is parked.
|
|
329
|
+
*/
|
|
330
|
+
function dialogGuardFor(forSession, canElicit = false) {
|
|
331
|
+
const guard = guardFor(forSession, {
|
|
332
|
+
mode: () => resolveDialogMode({ projectDir: forSession.projectDir, canElicit }).mode,
|
|
333
|
+
probe: () => forSession.guarded.call("list_dialogs", {}),
|
|
334
|
+
press: (buttonLabel) => forSession.guarded.call("respond_to_dialog", { buttonLabel }),
|
|
335
|
+
elicit: () => (canElicit ? ctx.elicit : undefined),
|
|
336
|
+
isConnected: () => forSession.bridge.isConnected,
|
|
337
|
+
// The instance-aware reader, not a second one: it prefers
|
|
338
|
+
// status.<pid>.json over the shared file two editors of one project take
|
|
339
|
+
// turns writing, and it reports how old the snapshot is so a leftover
|
|
340
|
+
// from a crashed editor is not mistaken for a live modal.
|
|
341
|
+
readSnapshot: () => {
|
|
342
|
+
const proj = forSession.project.projectPath
|
|
343
|
+
?? forSession.bridge.getTarget().projectPath
|
|
344
|
+
?? null;
|
|
345
|
+
return proj ? readEngineSnapshot(proj) : null;
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
guard.startWatching();
|
|
349
|
+
return guard;
|
|
350
|
+
}
|
|
314
351
|
const getToolGraph = (forSession = primary) => {
|
|
315
352
|
const load = perSession.get(forSession);
|
|
316
353
|
// D1 again. A session whose surface failed to build has no graph of its
|
|
@@ -378,6 +415,11 @@ async function main() {
|
|
|
378
415
|
};
|
|
379
416
|
for (const load of loads)
|
|
380
417
|
discoverGuardsFor(load);
|
|
418
|
+
// A guard per registered editor, so a modal in the DESTINATION of a
|
|
419
|
+
// cross-editor call is seen by that editor's own guard rather than by
|
|
420
|
+
// whichever session happened to originate the call.
|
|
421
|
+
for (const s of sessions.list())
|
|
422
|
+
dialogGuardFor(s);
|
|
381
423
|
/**
|
|
382
424
|
* The dispatch surface for one session, built on demand (D1).
|
|
383
425
|
*
|
|
@@ -421,6 +463,10 @@ async function main() {
|
|
|
421
463
|
// its own surface exists.
|
|
422
464
|
sessions.prepareSession = async (session) => {
|
|
423
465
|
await ensureSessionLoad(session);
|
|
466
|
+
// Its own guard and its own watcher, before it is addressable. Without
|
|
467
|
+
// this an editor added at runtime had nothing watching it, so a modal
|
|
468
|
+
// there was never detected at all until a call happened to be routed in.
|
|
469
|
+
dialogGuardFor(session);
|
|
424
470
|
};
|
|
425
471
|
for (const session of sessions.list()) {
|
|
426
472
|
if (session.guards.size === 0)
|
|
@@ -549,24 +595,17 @@ async function main() {
|
|
|
549
595
|
isError: true,
|
|
550
596
|
};
|
|
551
597
|
}
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
// there is no action anywhere that quietly works around a stuck editor.
|
|
555
|
-
//
|
|
556
|
-
// The dialog handling mode is resolved HERE, per call, so it governs the
|
|
557
|
-
// whole surface rather than the two lifecycle actions that used to be
|
|
558
|
-
// the only things that consulted it.
|
|
598
|
+
// One guard per editor, shared by every route. The mode is read per
|
|
599
|
+
// call, so changing it takes effect without restarting anything.
|
|
559
600
|
const canElicit = clientAdvertisesElicitation(ctx.elicit);
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
});
|
|
566
|
-
if (gated) {
|
|
601
|
+
const guard = dialogGuardFor(session, canElicit);
|
|
602
|
+
// Actions served in this process never reach the bridge, so the bridge
|
|
603
|
+
// boundary cannot refuse them. Same guard, same decision.
|
|
604
|
+
const preflight = await guard.check(effectiveTaskName(tool, params), "action");
|
|
605
|
+
if (!preflight.allow) {
|
|
567
606
|
return {
|
|
568
607
|
content: withUpgradeNotice([
|
|
569
|
-
{ type: "text", text: JSON.stringify(
|
|
608
|
+
{ type: "text", text: JSON.stringify(preflight.refusal, null, 2) },
|
|
570
609
|
]),
|
|
571
610
|
isError: true,
|
|
572
611
|
};
|
|
@@ -648,10 +687,27 @@ async function main() {
|
|
|
648
687
|
}
|
|
649
688
|
try {
|
|
650
689
|
const task = await sessionRegistry.create(taskName, flowCtx, taskParams);
|
|
651
|
-
// Locks are acquired in the editor the call runs in,
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
690
|
+
// Locks are acquired in the editor the call runs in, and through the
|
|
691
|
+
// GUARDED bridge, so a lock request made while a modal is up is
|
|
692
|
+
// refused as a dialog rather than reported as somebody else holding
|
|
693
|
+
// the asset.
|
|
694
|
+
const result = await withAssetLocks(session.guarded, lockingCfg, taskName, taskParams, () => task.run(), session.lockOwnerId);
|
|
695
|
+
// A handler that makes several bridge calls can swallow a refusal and
|
|
696
|
+
// still report success, so ask the guard what it learned rather than
|
|
697
|
+
// trusting the shape. Nothing is re-run: the call already applied
|
|
698
|
+
// whatever it applied, and replaying it would double-apply.
|
|
699
|
+
// A task that failed for its own reasons reports that, not a dialog.
|
|
700
|
+
//
|
|
701
|
+
// But a failure DURING a modal is usually caused by it: the game thread
|
|
702
|
+
// is parked, so the call times out and the error says the editor was
|
|
703
|
+
// busy and suggests a bigger timeoutMs, which is the retry loop this
|
|
704
|
+
// gate exists to end. The task runner returns no data on a throw, so
|
|
705
|
+
// the refusal cannot be recognised from the result: ask the guard.
|
|
706
|
+
const failedUnderDialog = !result.success
|
|
707
|
+
&& !isDialogRefusal(result.data)
|
|
708
|
+
&& !DialogGuard.actionAllowed(effectiveTaskName(tool, params))
|
|
709
|
+
&& (await guard.check(effectiveTaskName(tool, params), "action")).allow === false;
|
|
710
|
+
if (!result.success && !isDialogRefusal(result.data) && !failedUnderDialog) {
|
|
655
711
|
const msg = result.error?.message ?? `Task ${taskName} failed`;
|
|
656
712
|
return {
|
|
657
713
|
content: withUpgradeNotice([
|
|
@@ -662,56 +718,71 @@ async function main() {
|
|
|
662
718
|
isError: true,
|
|
663
719
|
};
|
|
664
720
|
}
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
721
|
+
// An allow-listed read still SAYS a dialog is up. get_status is the
|
|
722
|
+
// first call every client makes, and reporting a healthy editor while
|
|
723
|
+
// the game thread is parked is the one answer it must never give.
|
|
724
|
+
if (DialogGuard.actionAllowed(effectiveTaskName(tool, params))) {
|
|
725
|
+
// respond_to_dialog may have just cleared it. Nothing re-probes for
|
|
726
|
+
// an allow-listed bridge method (guardCall returns before check, and
|
|
727
|
+
// observe refuses to clear on a modal-safe reply), so this call came
|
|
728
|
+
// back stamped as blocked and told the caller to make the call it had
|
|
729
|
+
// just made.
|
|
730
|
+
if (effectiveTaskName(tool, params) === "editor.respond_to_dialog" && result.success) {
|
|
731
|
+
// refresh, NOT check. check applies the mode, so in interactive it
|
|
732
|
+
// raised a form for whatever prompt this answer surfaced and
|
|
733
|
+
// pressed a button on it, unasked, and then threw the decision
|
|
734
|
+
// away. Only the state needs correcting here.
|
|
735
|
+
await guard.refresh();
|
|
736
|
+
}
|
|
737
|
+
const seen = guard.current;
|
|
738
|
+
if (result.success)
|
|
739
|
+
stampBlockedEditor(result.data, seen);
|
|
740
|
+
}
|
|
741
|
+
// Whatever the route, the caller gets ONE refusal shape, built here.
|
|
742
|
+
//
|
|
743
|
+
// A modal appearing between the preflight and the inner call means the
|
|
744
|
+
// plugin refuses, and its payload lands in result.data. Passing that
|
|
745
|
+
// through handed the caller a second shape with no dialogMode and, in
|
|
746
|
+
// defer, the very press calls defer exists to withhold.
|
|
747
|
+
//
|
|
748
|
+
// Actions allowed through a modal are exempt: reading the dialog and
|
|
749
|
+
// answering it must not come back refused because of the dialog they
|
|
750
|
+
// are about.
|
|
751
|
+
if (!DialogGuard.actionAllowed(effectiveTaskName(tool, params))) {
|
|
752
|
+
const fromPlugin = isDialogRefusal(result.data)
|
|
753
|
+
? result.data
|
|
754
|
+
: null;
|
|
755
|
+
if (fromPlugin)
|
|
756
|
+
guard.observe("__refused__", fromPlugin);
|
|
757
|
+
const blocking = guard.current;
|
|
758
|
+
if (blocking) {
|
|
759
|
+
// A refusal means nothing ran. Anything else means the call had
|
|
760
|
+
// already started when the dialog appeared, so it may have applied
|
|
761
|
+
// part of its work; say so rather than implying it did nothing.
|
|
762
|
+
const started = fromPlugin === null;
|
|
687
763
|
return {
|
|
688
764
|
content: withUpgradeNotice([
|
|
689
|
-
{
|
|
765
|
+
{
|
|
766
|
+
type: "text",
|
|
767
|
+
text: JSON.stringify(started
|
|
768
|
+
? {
|
|
769
|
+
...guard.refusal(effectiveTaskName(tool, params), blocking),
|
|
770
|
+
partiallyApplied: true,
|
|
771
|
+
note: `'${taskName}' had already started when the dialog appeared, so it may `
|
|
772
|
+
+ "have applied some of its changes. Answer the dialog, then read the "
|
|
773
|
+
+ "state back before deciding whether to run it again.",
|
|
774
|
+
// Whatever it did manage to return, kept rather than
|
|
775
|
+
// dropped: a mutation that completed still has the
|
|
776
|
+
// path it created in here.
|
|
777
|
+
partialResult: result.data ?? null,
|
|
778
|
+
}
|
|
779
|
+
: guard.refusal(effectiveTaskName(tool, params), blocking), null, 2),
|
|
780
|
+
},
|
|
690
781
|
...attribution(session),
|
|
691
782
|
]),
|
|
692
783
|
isError: true,
|
|
693
784
|
};
|
|
694
785
|
}
|
|
695
|
-
// Answered through the elicitation form, so the dialog is gone and
|
|
696
|
-
// the caller is told to make the call again rather than being handed
|
|
697
|
-
// a refusal that is no longer true.
|
|
698
|
-
return {
|
|
699
|
-
content: withUpgradeNotice([
|
|
700
|
-
{
|
|
701
|
-
type: "text",
|
|
702
|
-
text: JSON.stringify({
|
|
703
|
-
success: false,
|
|
704
|
-
dialogAnswered: true,
|
|
705
|
-
dialogTitle: info.dialogTitle,
|
|
706
|
-
retry: taskName,
|
|
707
|
-
note: "The blocking dialog was answered by the user, so the editor is running "
|
|
708
|
-
+ `again. '${taskName}' did not run; call it again.`,
|
|
709
|
-
}, null, 2),
|
|
710
|
-
},
|
|
711
|
-
...attribution(session),
|
|
712
|
-
]),
|
|
713
|
-
isError: true,
|
|
714
|
-
};
|
|
715
786
|
}
|
|
716
787
|
const stringify = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
|
|
717
788
|
// Preserve directive responses (execute_python workaround tracking).
|
|
@@ -740,6 +811,26 @@ async function main() {
|
|
|
740
811
|
};
|
|
741
812
|
}
|
|
742
813
|
catch (e) {
|
|
814
|
+
// A refusal can arrive as a throw: acquiring an asset lock is a bridge
|
|
815
|
+
// call, so it is refused like any other, and locking reports failure by
|
|
816
|
+
// throwing. Shaped through the guard so a caller gets the same payload
|
|
817
|
+
// whether the refusal came back as a result or as an exception.
|
|
818
|
+
const thrownRefusal = e instanceof McpError && isDialogRefusal(e.details)
|
|
819
|
+
? e.details
|
|
820
|
+
: null;
|
|
821
|
+
if (thrownRefusal) {
|
|
822
|
+
guard.observe("__refused__", thrownRefusal);
|
|
823
|
+
const blocking = guard.current;
|
|
824
|
+
if (blocking) {
|
|
825
|
+
return {
|
|
826
|
+
content: withUpgradeNotice([
|
|
827
|
+
{ type: "text", text: JSON.stringify(guard.refusal(effectiveTaskName(tool, params), blocking), null, 2) },
|
|
828
|
+
...attribution(session),
|
|
829
|
+
]),
|
|
830
|
+
isError: true,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
}
|
|
743
834
|
const msg = e instanceof Error ? e.message : String(e);
|
|
744
835
|
const code = e instanceof McpError ? e.code : "UNKNOWN";
|
|
745
836
|
return {
|
|
@@ -820,6 +911,21 @@ async function main() {
|
|
|
820
911
|
isError: true,
|
|
821
912
|
};
|
|
822
913
|
}
|
|
914
|
+
// The flow tool reaches the editor through the same guarded bridge, so
|
|
915
|
+
// its steps are refused individually. This covers the run being STARTED
|
|
916
|
+
// while a modal is already up, and flow(list)/flow(plan), which are
|
|
917
|
+
// in-process and never touch the bridge at all.
|
|
918
|
+
const flowGuard = dialogGuardFor(session, clientAdvertisesElicitation(ctx.elicit));
|
|
919
|
+
const flowCheck = await flowGuard.check(`${flowTool.name}.${String(params.action ?? "")}`, "action");
|
|
920
|
+
if (!flowCheck.allow) {
|
|
921
|
+
return {
|
|
922
|
+
content: withUpgradeNotice([
|
|
923
|
+
{ type: "text", text: JSON.stringify(flowCheck.refusal, null, 2) },
|
|
924
|
+
...attribution(session),
|
|
925
|
+
]),
|
|
926
|
+
isError: true,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
823
929
|
const result = await flowTool.handler(sessionContext(ctx, session), params);
|
|
824
930
|
const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
825
931
|
return { content: withUpgradeNotice([{ type: "text", text }, ...attribution(session)]) };
|
|
@@ -913,12 +1019,29 @@ async function buildSessionLoad(session, packageVersion, multi) {
|
|
|
913
1019
|
// its own.
|
|
914
1020
|
let catalog = null;
|
|
915
1021
|
let source = "";
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1022
|
+
// The guarded bridge, not the raw one. This is the first call the server
|
|
1023
|
+
// makes against an editor, so sending it raw meant a modal was met by
|
|
1024
|
+
// the one route with no gate on it: the refusal came back, the surface
|
|
1025
|
+
// fell back to a cache, and nothing was latched for the calls that
|
|
1026
|
+
// followed.
|
|
1027
|
+
const bridge = session.guarded;
|
|
1028
|
+
if (!session.bridge.isConnected) {
|
|
1029
|
+
await session.bridge.connect(2000).catch(() => { });
|
|
919
1030
|
}
|
|
920
|
-
if (bridge.isConnected) {
|
|
1031
|
+
if (session.bridge.isConnected) {
|
|
921
1032
|
catalog = (await bridge.call("epic_list_toolsets", { includeSchemas: true }, 20000));
|
|
1033
|
+
// A modal refuses this like anything else, and the refusal has no
|
|
1034
|
+
// toolsets, so it read as "this editor advertises none" and the surface
|
|
1035
|
+
// silently fell back to a cache. Say what actually happened, and let
|
|
1036
|
+
// the guard learn from it rather than swallowing the evidence.
|
|
1037
|
+
const read = catalogOrRefusal(catalog);
|
|
1038
|
+
if (read.refusedByDialog) {
|
|
1039
|
+
existingGuard(session)?.observe("epic_list_toolsets", catalog);
|
|
1040
|
+
warn("epic", `${session.name}: the Epic toolset list was refused because a modal dialog is blocking `
|
|
1041
|
+
+ "the editor, so its tools are being taken from the cache. Answer the dialog and "
|
|
1042
|
+
+ "re-register the editor to pick up the live set.");
|
|
1043
|
+
}
|
|
1044
|
+
catalog = read.catalog;
|
|
922
1045
|
if (catalog?.toolsets?.length) {
|
|
923
1046
|
saveCatalogCache(configDir, catalog, project.engineAssociation);
|
|
924
1047
|
source = "live editor";
|