@godot-mcp/control-plane 0.0.0 → 0.1.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/LICENSE +21 -0
- package/README.md +105 -2
- package/dist/audit/jsonlAuditSink.d.ts +33 -0
- package/dist/audit/jsonlAuditSink.d.ts.map +1 -0
- package/dist/audit/jsonlAuditSink.js +57 -0
- package/dist/audit/jsonlAuditSink.js.map +1 -0
- package/dist/audit/redact.d.ts +2 -0
- package/dist/audit/redact.d.ts.map +1 -0
- package/dist/audit/redact.js +44 -0
- package/dist/audit/redact.js.map +1 -0
- package/dist/auth/pairingDescriptor.d.ts +44 -0
- package/dist/auth/pairingDescriptor.d.ts.map +1 -0
- package/dist/auth/pairingDescriptor.js +91 -0
- package/dist/auth/pairingDescriptor.js.map +1 -0
- package/dist/auth/runtimeDirectory.d.ts +3 -0
- package/dist/auth/runtimeDirectory.d.ts.map +1 -0
- package/dist/auth/runtimeDirectory.js +30 -0
- package/dist/auth/runtimeDirectory.js.map +1 -0
- package/dist/auth/sessionCrypto.d.ts +18 -0
- package/dist/auth/sessionCrypto.d.ts.map +1 -0
- package/dist/auth/sessionCrypto.js +118 -0
- package/dist/auth/sessionCrypto.js.map +1 -0
- package/dist/editor/editorMutationService.d.ts +21 -0
- package/dist/editor/editorMutationService.d.ts.map +1 -0
- package/dist/editor/editorMutationService.js +120 -0
- package/dist/editor/editorMutationService.js.map +1 -0
- package/dist/editor/mutationLedger.d.ts +30 -0
- package/dist/editor/mutationLedger.d.ts.map +1 -0
- package/dist/editor/mutationLedger.js +133 -0
- package/dist/editor/mutationLedger.js.map +1 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +23 -0
- package/dist/errors.js.map +1 -0
- package/dist/evidence/evidenceStore.d.ts +63 -0
- package/dist/evidence/evidenceStore.d.ts.map +1 -0
- package/dist/evidence/evidenceStore.js +350 -0
- package/dist/evidence/evidenceStore.js.map +1 -0
- package/dist/extensions/extensionRegistry.d.ts +33 -0
- package/dist/extensions/extensionRegistry.d.ts.map +1 -0
- package/dist/extensions/extensionRegistry.js +37 -0
- package/dist/extensions/extensionRegistry.js.map +1 -0
- package/dist/help/coreHelp.d.ts +10 -0
- package/dist/help/coreHelp.d.ts.map +1 -0
- package/dist/help/coreHelp.js +82 -0
- package/dist/help/coreHelp.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/policy/authorize.d.ts +5 -0
- package/dist/policy/authorize.d.ts.map +1 -0
- package/dist/policy/authorize.js +19 -0
- package/dist/policy/authorize.js.map +1 -0
- package/dist/policy/capabilities.d.ts +36 -0
- package/dist/policy/capabilities.d.ts.map +1 -0
- package/dist/policy/capabilities.js +129 -0
- package/dist/policy/capabilities.js.map +1 -0
- package/dist/project/artifactStore.d.ts +32 -0
- package/dist/project/artifactStore.d.ts.map +1 -0
- package/dist/project/artifactStore.js +172 -0
- package/dist/project/artifactStore.js.map +1 -0
- package/dist/project/pathPolicy.d.ts +4 -0
- package/dist/project/pathPolicy.d.ts.map +1 -0
- package/dist/project/pathPolicy.js +74 -0
- package/dist/project/pathPolicy.js.map +1 -0
- package/dist/project/projectConfig.d.ts +10 -0
- package/dist/project/projectConfig.d.ts.map +1 -0
- package/dist/project/projectConfig.js +63 -0
- package/dist/project/projectConfig.js.map +1 -0
- package/dist/project/projectIdentity.d.ts +8 -0
- package/dist/project/projectIdentity.d.ts.map +1 -0
- package/dist/project/projectIdentity.js +58 -0
- package/dist/project/projectIdentity.js.map +1 -0
- package/dist/project/projectJobJournal.d.ts +49 -0
- package/dist/project/projectJobJournal.d.ts.map +1 -0
- package/dist/project/projectJobJournal.js +67 -0
- package/dist/project/projectJobJournal.js.map +1 -0
- package/dist/project/projectJobService.d.ts +52 -0
- package/dist/project/projectJobService.d.ts.map +1 -0
- package/dist/project/projectJobService.js +289 -0
- package/dist/project/projectJobService.js.map +1 -0
- package/dist/project/projectMutationService.d.ts +40 -0
- package/dist/project/projectMutationService.d.ts.map +1 -0
- package/dist/project/projectMutationService.js +145 -0
- package/dist/project/projectMutationService.js.map +1 -0
- package/dist/project/projectPreflight.d.ts +7 -0
- package/dist/project/projectPreflight.d.ts.map +1 -0
- package/dist/project/projectPreflight.js +50 -0
- package/dist/project/projectPreflight.js.map +1 -0
- package/dist/project/projectProcess.d.ts +40 -0
- package/dist/project/projectProcess.d.ts.map +1 -0
- package/dist/project/projectProcess.js +196 -0
- package/dist/project/projectProcess.js.map +1 -0
- package/dist/project/projectService.d.ts +20 -0
- package/dist/project/projectService.d.ts.map +1 -0
- package/dist/project/projectService.js +35 -0
- package/dist/project/projectService.js.map +1 -0
- package/dist/project/secureJournalFile.d.ts +3 -0
- package/dist/project/secureJournalFile.d.ts.map +1 -0
- package/dist/project/secureJournalFile.js +56 -0
- package/dist/project/secureJournalFile.js.map +1 -0
- package/dist/runtime/debugTokenStore.d.ts +41 -0
- package/dist/runtime/debugTokenStore.d.ts.map +1 -0
- package/dist/runtime/debugTokenStore.js +105 -0
- package/dist/runtime/debugTokenStore.js.map +1 -0
- package/dist/runtime/debuggerClient.d.ts +14 -0
- package/dist/runtime/debuggerClient.d.ts.map +1 -0
- package/dist/runtime/debuggerClient.js +21 -0
- package/dist/runtime/debuggerClient.js.map +1 -0
- package/dist/runtime/inputReceipt.d.ts +15 -0
- package/dist/runtime/inputReceipt.d.ts.map +1 -0
- package/dist/runtime/inputReceipt.js +36 -0
- package/dist/runtime/inputReceipt.js.map +1 -0
- package/dist/runtime/runtimeDescriptor.d.ts +50 -0
- package/dist/runtime/runtimeDescriptor.d.ts.map +1 -0
- package/dist/runtime/runtimeDescriptor.js +175 -0
- package/dist/runtime/runtimeDescriptor.js.map +1 -0
- package/dist/runtime/runtimeProcess.d.ts +39 -0
- package/dist/runtime/runtimeProcess.d.ts.map +1 -0
- package/dist/runtime/runtimeProcess.js +180 -0
- package/dist/runtime/runtimeProcess.js.map +1 -0
- package/dist/runtime/runtimeService.d.ts +130 -0
- package/dist/runtime/runtimeService.d.ts.map +1 -0
- package/dist/runtime/runtimeService.js +919 -0
- package/dist/runtime/runtimeService.js.map +1 -0
- package/dist/session/sessionService.d.ts +52 -0
- package/dist/session/sessionService.d.ts.map +1 -0
- package/dist/session/sessionService.js +72 -0
- package/dist/session/sessionService.js.map +1 -0
- package/dist/unsafe/unsafeFixtureAuthority.d.ts +27 -0
- package/dist/unsafe/unsafeFixtureAuthority.d.ts.map +1 -0
- package/dist/unsafe/unsafeFixtureAuthority.js +130 -0
- package/dist/unsafe/unsafeFixtureAuthority.js.map +1 -0
- package/dist/unsafe/unsafeFixtureProcess.d.ts +30 -0
- package/dist/unsafe/unsafeFixtureProcess.d.ts.map +1 -0
- package/dist/unsafe/unsafeFixtureProcess.js +119 -0
- package/dist/unsafe/unsafeFixtureProcess.js.map +1 -0
- package/dist/unsafe/unsafeFixtureService.d.ts +41 -0
- package/dist/unsafe/unsafeFixtureService.d.ts.map +1 -0
- package/dist/unsafe/unsafeFixtureService.js +162 -0
- package/dist/unsafe/unsafeFixtureService.js.map +1 -0
- package/dist/visual/pngComparison.d.ts +12 -0
- package/dist/visual/pngComparison.d.ts.map +1 -0
- package/dist/visual/pngComparison.js +136 -0
- package/dist/visual/pngComparison.js.map +1 -0
- package/dist/visual/scenarioService.d.ts +50 -0
- package/dist/visual/scenarioService.d.ts.map +1 -0
- package/dist/visual/scenarioService.js +421 -0
- package/dist/visual/scenarioService.js.map +1 -0
- package/dist/visual/visualService.d.ts +27 -0
- package/dist/visual/visualService.d.ts.map +1 -0
- package/dist/visual/visualService.js +70 -0
- package/dist/visual/visualService.js.map +1 -0
- package/package.json +26 -6
- package/index.js +0 -1
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
4
|
+
import { canonicalJson, DebugStopResultSchema, InputOperationResultSchema, MonitorSnapshotSchema, ProfileJobReceiptSchema, ProfileResultSchema, RuntimeLaunchPinsSchema } from "@godot-mcp/protocol";
|
|
5
|
+
import { GodotMcpException } from "../errors.js";
|
|
6
|
+
import { resolveProjectPath } from "../project/pathPolicy.js";
|
|
7
|
+
import { DebuggerClientError } from "./debuggerClient.js";
|
|
8
|
+
import { DebugTokenStore, DebugTokenStoreError } from "./debugTokenStore.js";
|
|
9
|
+
import { createRuntimeDescriptor, } from "./runtimeDescriptor.js";
|
|
10
|
+
import { assertLoopbackListenerOwnedByProcess, assertLoopbackListenersOwnedByProcess, OwnedGodotProcess, } from "./runtimeProcess.js";
|
|
11
|
+
import { inputTraceEvents, traceSha256 } from "./inputReceipt.js";
|
|
12
|
+
class AuthenticatedDebuggerClient {
|
|
13
|
+
command;
|
|
14
|
+
handle;
|
|
15
|
+
connected = true;
|
|
16
|
+
stopped = false;
|
|
17
|
+
stopSequence = 0;
|
|
18
|
+
constructor(command, handle) {
|
|
19
|
+
this.command = command;
|
|
20
|
+
this.handle = handle;
|
|
21
|
+
}
|
|
22
|
+
async request(command, adapterArguments, timeoutMs = 10_000) {
|
|
23
|
+
if (!this.connected)
|
|
24
|
+
throw runtimeError("TRANSPORT_ERROR", "Authenticated debugger channel is closed", true);
|
|
25
|
+
const response = await this.command("debug_adapter", { handle: this.handle, command, adapterArguments }, timeoutMs);
|
|
26
|
+
if (!isRecord(response))
|
|
27
|
+
throw runtimeError("GODOT_RUNTIME_ERROR", "Authenticated debugger returned a malformed response");
|
|
28
|
+
if (command === "pause") {
|
|
29
|
+
const body = isRecord(response.body) ? response.body : {};
|
|
30
|
+
this.stopped = true;
|
|
31
|
+
this.stopSequence = Math.max(this.stopSequence, integerOr(body.sequence, this.stopSequence + 1));
|
|
32
|
+
}
|
|
33
|
+
else if (command === "continue" || command === "next" || command === "stepIn") {
|
|
34
|
+
this.stopped = false;
|
|
35
|
+
}
|
|
36
|
+
await this.refresh();
|
|
37
|
+
return response;
|
|
38
|
+
}
|
|
39
|
+
async nextStop(afterSequence, timeoutMs) {
|
|
40
|
+
const response = await this.command("debug_adapter", {
|
|
41
|
+
handle: this.handle,
|
|
42
|
+
command: "wait",
|
|
43
|
+
adapterArguments: { afterSequence, timeoutMs },
|
|
44
|
+
}, timeoutMs + 250);
|
|
45
|
+
if (!isRecord(response) || !Number.isInteger(response.sequence) || typeof response.reason !== "string") {
|
|
46
|
+
throw runtimeError("GODOT_RUNTIME_ERROR", "Authenticated debugger returned a malformed stop event");
|
|
47
|
+
}
|
|
48
|
+
const eventSequence = Number(response.sequence);
|
|
49
|
+
await this.refresh();
|
|
50
|
+
return { sequence: eventSequence, reason: response.reason, body: { reason: response.reason, threadId: 1 } };
|
|
51
|
+
}
|
|
52
|
+
snapshot() {
|
|
53
|
+
return { connected: this.connected, stopped: this.stopped, stopSequence: this.stopSequence };
|
|
54
|
+
}
|
|
55
|
+
markRunning(afterSequence = this.stopSequence) {
|
|
56
|
+
if (this.stopSequence <= afterSequence)
|
|
57
|
+
this.stopped = false;
|
|
58
|
+
}
|
|
59
|
+
async refresh() {
|
|
60
|
+
if (!this.connected)
|
|
61
|
+
return;
|
|
62
|
+
const response = await this.command("debug_adapter", {
|
|
63
|
+
handle: this.handle,
|
|
64
|
+
command: "status",
|
|
65
|
+
adapterArguments: {},
|
|
66
|
+
});
|
|
67
|
+
if (!isRecord(response) || typeof response.connected !== "boolean" || typeof response.stopped !== "boolean" || !Number.isInteger(response.stopSequence)) {
|
|
68
|
+
throw runtimeError("GODOT_RUNTIME_ERROR", "Authenticated debugger returned malformed status");
|
|
69
|
+
}
|
|
70
|
+
this.connected = response.connected;
|
|
71
|
+
this.stopped = response.stopped;
|
|
72
|
+
this.stopSequence = Number(response.stopSequence);
|
|
73
|
+
}
|
|
74
|
+
async close() {
|
|
75
|
+
this.connected = false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function runtimeError(code, message, retryable = false) {
|
|
79
|
+
return new GodotMcpException({
|
|
80
|
+
code,
|
|
81
|
+
message,
|
|
82
|
+
retryable,
|
|
83
|
+
correlationId: randomUUID(),
|
|
84
|
+
partialEffects: false,
|
|
85
|
+
rollback: "not_needed",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
export class RuntimeService {
|
|
89
|
+
dependencies;
|
|
90
|
+
state = "idle";
|
|
91
|
+
generation = 0;
|
|
92
|
+
handle = null;
|
|
93
|
+
scenePath = null;
|
|
94
|
+
process = null;
|
|
95
|
+
processStopped = false;
|
|
96
|
+
descriptor = null;
|
|
97
|
+
closePromise;
|
|
98
|
+
disconnectPromise;
|
|
99
|
+
launchPromise;
|
|
100
|
+
cleanupPromise;
|
|
101
|
+
debuggerCleaned = true;
|
|
102
|
+
lifecycleEpoch = 0;
|
|
103
|
+
closed = false;
|
|
104
|
+
operationTail = Promise.resolve();
|
|
105
|
+
debuggerClient = null;
|
|
106
|
+
debuggerGeneration = 0;
|
|
107
|
+
debuggerSessionId = null;
|
|
108
|
+
debugTokens = new DebugTokenStore();
|
|
109
|
+
breakpointSources = new Map();
|
|
110
|
+
opaqueProfileId = null;
|
|
111
|
+
constructor(dependencies) {
|
|
112
|
+
this.dependencies = dependencies;
|
|
113
|
+
}
|
|
114
|
+
snapshot() {
|
|
115
|
+
return {
|
|
116
|
+
state: this.state,
|
|
117
|
+
handle: this.handle ? { ...this.handle } : null,
|
|
118
|
+
scenePath: this.scenePath,
|
|
119
|
+
process: this.process ? { pid: this.process.pid, fingerprint: this.process.fingerprint } : null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
launch(input) {
|
|
123
|
+
if (this.closed)
|
|
124
|
+
return Promise.reject(runtimeError("CONFLICT", "Runtime service is closed"));
|
|
125
|
+
if (this.disconnectPromise)
|
|
126
|
+
return Promise.reject(runtimeError("CONFLICT", "Runtime service is disconnecting"));
|
|
127
|
+
if (this.launchPromise)
|
|
128
|
+
return Promise.reject(runtimeError("CONFLICT", "A runtime launch is already in progress"));
|
|
129
|
+
if (!["idle", "stopped"].includes(this.state))
|
|
130
|
+
return Promise.reject(runtimeError("CONFLICT", "A runtime is already active"));
|
|
131
|
+
const epoch = this.lifecycleEpoch;
|
|
132
|
+
const activeLaunch = this.launchGeneration(input, epoch).finally(() => {
|
|
133
|
+
if (this.launchPromise === activeLaunch)
|
|
134
|
+
this.launchPromise = undefined;
|
|
135
|
+
});
|
|
136
|
+
this.launchPromise = activeLaunch;
|
|
137
|
+
return activeLaunch;
|
|
138
|
+
}
|
|
139
|
+
async launchGeneration(input, epoch) {
|
|
140
|
+
const sessionId = this.dependencies.sessionId();
|
|
141
|
+
if (!sessionId)
|
|
142
|
+
throw runtimeError("NOT_ATTACHED", "Godot editor addon is not attached", true);
|
|
143
|
+
this.generation += 1;
|
|
144
|
+
this.handle = { runId: randomUUID(), generation: this.generation };
|
|
145
|
+
this.scenePath = input.scenePath;
|
|
146
|
+
this.processStopped = false;
|
|
147
|
+
try {
|
|
148
|
+
await this.cleanupDebugger(true);
|
|
149
|
+
this.assertLaunchCurrent(epoch);
|
|
150
|
+
this.state = "preparing";
|
|
151
|
+
const descriptorInput = {
|
|
152
|
+
project: this.dependencies.project,
|
|
153
|
+
sessionId,
|
|
154
|
+
runId: this.handle.runId,
|
|
155
|
+
generation: this.handle.generation,
|
|
156
|
+
scenePath: input.scenePath,
|
|
157
|
+
...(input.pins ? { pins: input.pins } : {}),
|
|
158
|
+
};
|
|
159
|
+
this.descriptor = await (this.dependencies.createDescriptor ?? createRuntimeDescriptor)(descriptorInput);
|
|
160
|
+
const descriptor = this.descriptor;
|
|
161
|
+
this.assertLaunchCurrent(epoch);
|
|
162
|
+
this.debuggerCleaned = false;
|
|
163
|
+
const prepared = await this.dependencies.prepare({ descriptor: descriptor.descriptor });
|
|
164
|
+
this.assertLaunchCurrent(epoch);
|
|
165
|
+
if (!Number.isInteger(prepared.debugPort) || prepared.debugPort < 1 || prepared.debugPort > 65_535) {
|
|
166
|
+
throw runtimeError("GODOT_RUNTIME_ERROR", "Editor reported an invalid debugger port");
|
|
167
|
+
}
|
|
168
|
+
if (this.dependencies.requireAuthenticatedDebuggerMetadata && (prepared.editorPid === undefined || prepared.debugTransport !== "authenticated-editor-session")) {
|
|
169
|
+
throw runtimeError("AUTHENTICATION_FAILED", "Authenticated editor debugger transport and process identity are required for runtime debugging");
|
|
170
|
+
}
|
|
171
|
+
if (prepared.editorPid !== undefined) {
|
|
172
|
+
if (!Number.isInteger(prepared.editorPid) || prepared.editorPid < 1) {
|
|
173
|
+
throw runtimeError("GODOT_RUNTIME_ERROR", "Editor reported an invalid process identity");
|
|
174
|
+
}
|
|
175
|
+
const verifyListener = this.dependencies.verifyEditorListener ?? this.dependencies.verifyDebuggerListener ?? assertLoopbackListenerOwnedByProcess;
|
|
176
|
+
await assertLoopbackListenersOwnedByProcess(prepared.editorPid, [prepared.debugPort], verifyListener);
|
|
177
|
+
this.assertLaunchCurrent(epoch);
|
|
178
|
+
}
|
|
179
|
+
this.state = "launching";
|
|
180
|
+
const launch = this.dependencies.launchProcess ?? ((launchInput) => OwnedGodotProcess.launch(launchInput));
|
|
181
|
+
this.process = await launch({
|
|
182
|
+
godotBin: this.dependencies.godotBin ?? process.env.GODOT_BIN ?? "godot",
|
|
183
|
+
projectRoot: this.dependencies.project.rootRealPath,
|
|
184
|
+
debugPort: prepared.debugPort,
|
|
185
|
+
descriptorPath: descriptor.path,
|
|
186
|
+
...(input.pins ? { pins: input.pins } : {}),
|
|
187
|
+
});
|
|
188
|
+
this.assertLaunchCurrent(epoch);
|
|
189
|
+
const ownedProcess = this.process;
|
|
190
|
+
void ownedProcess.wait().then(() => this.onProcessExit(ownedProcess), () => this.onProcessExit(ownedProcess));
|
|
191
|
+
this.state = "authenticating";
|
|
192
|
+
const ready = await this.dependencies.command("await_ready", { handle: this.handle }, input.startupTimeoutMs);
|
|
193
|
+
this.assertLaunchCurrent(epoch);
|
|
194
|
+
if (typeof ready !== "object" ||
|
|
195
|
+
ready === null ||
|
|
196
|
+
!("pid" in ready) ||
|
|
197
|
+
Number(ready.pid) !== this.process.pid) {
|
|
198
|
+
throw runtimeError("AUTHENTICATION_FAILED", "Authenticated runtime PID does not match the owned process");
|
|
199
|
+
}
|
|
200
|
+
if (input.pins) {
|
|
201
|
+
const observedPins = RuntimeLaunchPinsSchema.safeParse(ready.observedPins);
|
|
202
|
+
if (!observedPins.success || canonicalJson(observedPins.data) !== canonicalJson(input.pins)) {
|
|
203
|
+
throw runtimeError("AUTHENTICATION_FAILED", "Runtime did not apply the authenticated deterministic launch pins");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const readySessionId = Number(ready.debuggerSessionId);
|
|
207
|
+
if (this.dependencies.requireAuthenticatedDebuggerMetadata && (!Number.isInteger(readySessionId) || readySessionId < 0)) {
|
|
208
|
+
throw runtimeError("AUTHENTICATION_FAILED", "Authenticated runtime omitted its debugger session identity");
|
|
209
|
+
}
|
|
210
|
+
this.debuggerSessionId = Number.isInteger(readySessionId) && readySessionId >= 0 ? readySessionId : null;
|
|
211
|
+
if (this.dependencies.requireAuthenticatedDebuggerMetadata) {
|
|
212
|
+
await this.dependencies.command("certify_owner_pid", { handle: this.handle, ownerPid: this.process.pid }, 5_000);
|
|
213
|
+
}
|
|
214
|
+
await descriptor.consume?.();
|
|
215
|
+
this.assertLaunchCurrent(epoch);
|
|
216
|
+
if (this.processStopped) {
|
|
217
|
+
throw runtimeError("GODOT_RUNTIME_ERROR", "Owned runtime exited before launch completed");
|
|
218
|
+
}
|
|
219
|
+
if (prepared.debugTransport === "authenticated-editor-session" && this.debuggerSessionId !== null) {
|
|
220
|
+
this.attachAuthenticatedDebugger();
|
|
221
|
+
}
|
|
222
|
+
this.state = "running";
|
|
223
|
+
return { handle: { ...this.handle }, root: ready };
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
this.state = "failed";
|
|
227
|
+
await this.cleanup();
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
execute(input) {
|
|
232
|
+
return this.runExclusive(() => this.executeExclusive(input));
|
|
233
|
+
}
|
|
234
|
+
async executeExclusive(input) {
|
|
235
|
+
if (input.operation.startsWith("debug_")) {
|
|
236
|
+
this.assertHandle(input.handle);
|
|
237
|
+
try {
|
|
238
|
+
return await this.executeDebug(input);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
throw normalizeDebugError(error);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (input.operation === "monitor_snapshot" || input.operation.startsWith("profile_")) {
|
|
245
|
+
this.assertHandle(input.handle);
|
|
246
|
+
return this.executePerformance(input);
|
|
247
|
+
}
|
|
248
|
+
if (input.operation === "status") {
|
|
249
|
+
if (input.handle)
|
|
250
|
+
this.assertHandleIdentity(input.handle);
|
|
251
|
+
if (!this.handle || !["running", "paused"].includes(this.state))
|
|
252
|
+
return this.snapshot();
|
|
253
|
+
const runtimeStatus = await this.dependencies.command("status", { handle: this.handle });
|
|
254
|
+
if (typeof runtimeStatus !== "object" || runtimeStatus === null)
|
|
255
|
+
return this.snapshot();
|
|
256
|
+
if ("paused" in runtimeStatus && typeof runtimeStatus.paused === "boolean") {
|
|
257
|
+
this.state = runtimeStatus.paused ? "paused" : "running";
|
|
258
|
+
}
|
|
259
|
+
return { ...this.snapshot(), ...runtimeStatus };
|
|
260
|
+
}
|
|
261
|
+
this.assertHandle(input.handle);
|
|
262
|
+
if (input.operation === "stop") {
|
|
263
|
+
this.state = "stopping";
|
|
264
|
+
let stopError;
|
|
265
|
+
try {
|
|
266
|
+
await this.dependencies.command("stop", { ...input });
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
stopError = error;
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
await this.cleanup();
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
this.state = "failed";
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
if (stopError)
|
|
279
|
+
throw stopError;
|
|
280
|
+
return this.snapshot();
|
|
281
|
+
}
|
|
282
|
+
const timeoutMs = input.operation === "wait" ? input.timeoutMs + 1_000 : undefined;
|
|
283
|
+
const result = await this.dependencies.command(input.operation, { ...input }, timeoutMs);
|
|
284
|
+
if (input.operation === "pause")
|
|
285
|
+
this.state = "paused";
|
|
286
|
+
if (input.operation === "resume")
|
|
287
|
+
this.state = "running";
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
async executePerformance(input) {
|
|
291
|
+
if (input.operation === "monitor_snapshot") {
|
|
292
|
+
return MonitorSnapshotSchema.parse(await this.dependencies.command(input.operation, { ...input }));
|
|
293
|
+
}
|
|
294
|
+
if (input.operation === "profile_start") {
|
|
295
|
+
const receipt = ProfileJobReceiptSchema.parse(await this.dependencies.command(input.operation, { ...input }));
|
|
296
|
+
this.opaqueProfileId = receipt.jobToken;
|
|
297
|
+
return receipt;
|
|
298
|
+
}
|
|
299
|
+
if (this.opaqueProfileId === null || input.jobToken !== this.opaqueProfileId) {
|
|
300
|
+
throw runtimeError("STALE_HANDLE", "Profile job token is stale or unknown");
|
|
301
|
+
}
|
|
302
|
+
if (input.operation === "profile_result") {
|
|
303
|
+
const result = ProfileResultSchema.parse(await this.dependencies.command(input.operation, { ...input }));
|
|
304
|
+
if (result.evidence.jobToken !== input.jobToken) {
|
|
305
|
+
throw runtimeError("STALE_HANDLE", "Profile evidence belongs to a different job");
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
const receipt = ProfileJobReceiptSchema.parse(await this.dependencies.command(input.operation, { ...input }));
|
|
310
|
+
if (receipt.jobToken !== input.jobToken) {
|
|
311
|
+
throw runtimeError("STALE_HANDLE", "Profile receipt belongs to a different job");
|
|
312
|
+
}
|
|
313
|
+
return receipt;
|
|
314
|
+
}
|
|
315
|
+
attachAuthenticatedDebugger() {
|
|
316
|
+
this.debuggerClient = new AuthenticatedDebuggerClient(this.dependencies.command, { ...this.handle });
|
|
317
|
+
this.debuggerGeneration += 1;
|
|
318
|
+
this.debugTokens.clear();
|
|
319
|
+
this.breakpointSources.clear();
|
|
320
|
+
}
|
|
321
|
+
async executeDebug(input) {
|
|
322
|
+
await this.assertDebuggerBinding();
|
|
323
|
+
await this.debuggerClient?.refresh?.();
|
|
324
|
+
if (this.debuggerClient && !this.debuggerClient.snapshot().stopped)
|
|
325
|
+
this.debugTokens.clear();
|
|
326
|
+
if (input.operation === "debug_status") {
|
|
327
|
+
const snapshot = this.debuggerClient?.snapshot() ?? { connected: false, stopped: false, stopSequence: 0 };
|
|
328
|
+
const breakpointCount = [...this.breakpointSources.values()].reduce((total, lines) => total + lines.length, 0);
|
|
329
|
+
return { ...snapshot, breakpointCount };
|
|
330
|
+
}
|
|
331
|
+
const debuggerClient = this.requireDebuggerClient();
|
|
332
|
+
switch (input.operation) {
|
|
333
|
+
case "debug_wait": {
|
|
334
|
+
const stopped = await debuggerClient.nextStop(input.afterSequence, input.timeoutMs);
|
|
335
|
+
const live = debuggerClient.snapshot();
|
|
336
|
+
if (live.stopped && live.stopSequence === stopped.sequence)
|
|
337
|
+
this.bindDebugStop(stopped.sequence);
|
|
338
|
+
else
|
|
339
|
+
this.debugTokens.clear();
|
|
340
|
+
return projectDebugStop(stopped);
|
|
341
|
+
}
|
|
342
|
+
case "debug_pause":
|
|
343
|
+
if (debuggerClient.snapshot().stopped)
|
|
344
|
+
throw runtimeError("PRECONDITION_FAILED", "Godot debugger is already stopped");
|
|
345
|
+
await debuggerClient.request("pause", { threadId: 1 });
|
|
346
|
+
return debuggerClient.snapshot();
|
|
347
|
+
case "debug_continue":
|
|
348
|
+
if (!debuggerClient.snapshot().stopped)
|
|
349
|
+
throw runtimeError("PRECONDITION_FAILED", "Godot debugger must be stopped before continuing");
|
|
350
|
+
const continueSequence = debuggerClient.snapshot().stopSequence;
|
|
351
|
+
await debuggerClient.request("continue", { threadId: 1 });
|
|
352
|
+
debuggerClient.markRunning(continueSequence);
|
|
353
|
+
this.debugTokens.clear();
|
|
354
|
+
return debuggerClient.snapshot();
|
|
355
|
+
case "debug_step_over":
|
|
356
|
+
return this.stepDebugger(debuggerClient, "next");
|
|
357
|
+
case "debug_step_into":
|
|
358
|
+
return this.stepDebugger(debuggerClient, "stepIn");
|
|
359
|
+
case "debug_breakpoints_set":
|
|
360
|
+
return this.setDebugBreakpoints(debuggerClient, input);
|
|
361
|
+
case "debug_stack":
|
|
362
|
+
return this.debugStack(debuggerClient, input.offset, input.limit);
|
|
363
|
+
case "debug_variables": {
|
|
364
|
+
const stopSequence = this.bindDebugStop();
|
|
365
|
+
const frameId = this.debugTokens.resolveFrame(input.frameToken);
|
|
366
|
+
const reference = await this.scopeReference(debuggerClient, frameId, input.scope, stopSequence);
|
|
367
|
+
return this.readVariables(debuggerClient, reference, 1, input.offset, input.limit, stopSequence);
|
|
368
|
+
}
|
|
369
|
+
case "debug_children": {
|
|
370
|
+
const stopSequence = this.bindDebugStop();
|
|
371
|
+
const record = this.debugTokens.resolveVariable(input.variableToken);
|
|
372
|
+
return this.readVariables(debuggerClient, record.variablesReference, record.depth + 1, input.offset, input.limit, stopSequence);
|
|
373
|
+
}
|
|
374
|
+
case "debug_watch": {
|
|
375
|
+
const stopSequence = this.bindDebugStop();
|
|
376
|
+
const frameId = this.debugTokens.resolveFrame(input.frameToken);
|
|
377
|
+
const watches = [];
|
|
378
|
+
for (const selector of input.selectors)
|
|
379
|
+
watches.push(await this.resolveWatch(debuggerClient, frameId, selector, stopSequence));
|
|
380
|
+
return { watches };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
requireDebuggerClient() {
|
|
385
|
+
if (!this.debuggerClient || !this.debuggerClient.snapshot().connected) {
|
|
386
|
+
throw runtimeError("PRECONDITION_FAILED", "The authenticated Godot debugger is unavailable for the active runtime", true);
|
|
387
|
+
}
|
|
388
|
+
return this.debuggerClient;
|
|
389
|
+
}
|
|
390
|
+
async assertDebuggerBinding() {
|
|
391
|
+
if (this.debuggerSessionId === null || !this.handle)
|
|
392
|
+
return;
|
|
393
|
+
const status = await this.dependencies.command("debug_binding_status", { handle: this.handle });
|
|
394
|
+
if (!isRecord(status) || status.unambiguous !== true || status.activeSessionCount !== 1 || status.debuggerSessionId !== this.debuggerSessionId) {
|
|
395
|
+
this.debugTokens.clear();
|
|
396
|
+
throw runtimeError("AUTHENTICATION_FAILED", "The Godot debugger target is not the uniquely authenticated runtime session");
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
bindDebugStop(stopSequence) {
|
|
400
|
+
const debuggerClient = this.requireDebuggerClient();
|
|
401
|
+
const snapshot = debuggerClient.snapshot();
|
|
402
|
+
const sequence = stopSequence ?? snapshot.stopSequence;
|
|
403
|
+
if (!snapshot.stopped || sequence < 1 || snapshot.stopSequence !== sequence || !this.handle) {
|
|
404
|
+
this.debugTokens.clear();
|
|
405
|
+
throw runtimeError("PRECONDITION_FAILED", "Godot runtime is not stopped in a debuggable frame");
|
|
406
|
+
}
|
|
407
|
+
this.debugTokens.bind({
|
|
408
|
+
runId: this.handle.runId,
|
|
409
|
+
generation: this.handle.generation,
|
|
410
|
+
debuggerGeneration: this.debuggerGeneration,
|
|
411
|
+
stopSequence: sequence,
|
|
412
|
+
});
|
|
413
|
+
return sequence;
|
|
414
|
+
}
|
|
415
|
+
async stepDebugger(debuggerClient, command) {
|
|
416
|
+
const snapshot = debuggerClient.snapshot();
|
|
417
|
+
if (!snapshot.stopped)
|
|
418
|
+
throw runtimeError("PRECONDITION_FAILED", "Godot debugger must be stopped before stepping");
|
|
419
|
+
await debuggerClient.request(command, { threadId: 1 });
|
|
420
|
+
debuggerClient.markRunning(snapshot.stopSequence);
|
|
421
|
+
this.debugTokens.clear();
|
|
422
|
+
const stopped = await debuggerClient.nextStop(snapshot.stopSequence, 10_000);
|
|
423
|
+
this.bindDebugStop(stopped.sequence);
|
|
424
|
+
return projectDebugStop(stopped);
|
|
425
|
+
}
|
|
426
|
+
async setDebugBreakpoints(debuggerClient, input) {
|
|
427
|
+
const grouped = new Map();
|
|
428
|
+
for (const breakpoint of input.breakpoints) {
|
|
429
|
+
if (breakpoint.sourcePath.toLowerCase().startsWith("res://addons/godot_mcp/")) {
|
|
430
|
+
throw runtimeError("INVALID_REQUEST", "Debugger breakpoints cannot target the Godot MCP addon");
|
|
431
|
+
}
|
|
432
|
+
const absolutePath = await realpath(await resolveProjectPath(this.dependencies.project, breakpoint.sourcePath, "read"));
|
|
433
|
+
if (isPathInsideCaseInsensitive(join(this.dependencies.project.rootRealPath, "addons", "godot_mcp"), absolutePath)) {
|
|
434
|
+
throw runtimeError("INVALID_REQUEST", "Debugger breakpoints cannot target the Godot MCP addon");
|
|
435
|
+
}
|
|
436
|
+
const entries = grouped.get(absolutePath) ?? [];
|
|
437
|
+
entries.push(breakpoint);
|
|
438
|
+
grouped.set(absolutePath, entries);
|
|
439
|
+
}
|
|
440
|
+
const previous = new Map([...this.breakpointSources].map(([path, lines]) => [path, [...lines]]));
|
|
441
|
+
const sources = new Set([...previous.keys(), ...grouped.keys()]);
|
|
442
|
+
const results = [];
|
|
443
|
+
const applied = [];
|
|
444
|
+
try {
|
|
445
|
+
for (const absolutePath of sources) {
|
|
446
|
+
const entries = grouped.get(absolutePath) ?? [];
|
|
447
|
+
applied.push(absolutePath);
|
|
448
|
+
const response = await debuggerClient.request("setBreakpoints", {
|
|
449
|
+
source: { name: absolutePath.split(sep).at(-1), path: absolutePath },
|
|
450
|
+
breakpoints: entries.map((entry) => ({ line: entry.line })),
|
|
451
|
+
});
|
|
452
|
+
const returned = bodyArray(response, "breakpoints");
|
|
453
|
+
for (const [index, entry] of entries.entries()) {
|
|
454
|
+
const raw = isRecord(returned[index]) ? returned[index] : {};
|
|
455
|
+
results.push({
|
|
456
|
+
sourcePath: entry.sourcePath,
|
|
457
|
+
requestedLine: entry.line,
|
|
458
|
+
verified: raw.verified === true,
|
|
459
|
+
resolvedLine: integerOr(raw.line, entry.line),
|
|
460
|
+
...(typeof raw.message === "string" ? { message: raw.message.slice(0, 512) } : {}),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
let rollbackFailed = false;
|
|
467
|
+
for (const absolutePath of applied.reverse()) {
|
|
468
|
+
try {
|
|
469
|
+
const lines = previous.get(absolutePath) ?? [];
|
|
470
|
+
await debuggerClient.request("setBreakpoints", {
|
|
471
|
+
source: { name: absolutePath.split(sep).at(-1), path: absolutePath },
|
|
472
|
+
breakpoints: lines.map((line) => ({ line })),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
rollbackFailed = true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (rollbackFailed) {
|
|
480
|
+
await this.stopProcess().catch(() => undefined);
|
|
481
|
+
await this.cleanup().catch(() => undefined);
|
|
482
|
+
this.state = "failed";
|
|
483
|
+
throw runtimeError("TRANSPORT_ERROR", "Breakpoint replacement failed and could not be rolled back", true);
|
|
484
|
+
}
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
this.breakpointSources.clear();
|
|
488
|
+
for (const [absolutePath, entries] of grouped) {
|
|
489
|
+
if (entries.length > 0)
|
|
490
|
+
this.breakpointSources.set(absolutePath, entries.map((entry) => entry.line));
|
|
491
|
+
}
|
|
492
|
+
return { breakpoints: results };
|
|
493
|
+
}
|
|
494
|
+
async debugStack(debuggerClient, offset, limit) {
|
|
495
|
+
const stopSequence = this.bindDebugStop();
|
|
496
|
+
const response = await debuggerClient.request("stackTrace", { threadId: 1, startFrame: offset, levels: limit });
|
|
497
|
+
this.bindDebugStop(stopSequence);
|
|
498
|
+
const rawFrames = bodyArray(response, "stackFrames").slice(0, limit);
|
|
499
|
+
const frames = rawFrames.flatMap((raw) => {
|
|
500
|
+
if (!isRecord(raw) || !Number.isInteger(raw.id))
|
|
501
|
+
return [];
|
|
502
|
+
const rawSourcePath = isRecord(raw.source) && typeof raw.source.path === "string" ? raw.source.path : undefined;
|
|
503
|
+
if (rawSourcePath?.toLowerCase().startsWith("res://addons/godot_mcp/"))
|
|
504
|
+
return [];
|
|
505
|
+
const source = rawSourcePath
|
|
506
|
+
? this.projectSourcePath(rawSourcePath)
|
|
507
|
+
: undefined;
|
|
508
|
+
return [{
|
|
509
|
+
frameToken: this.debugTokens.issueFrame(Number(raw.id)),
|
|
510
|
+
name: boundedText(raw.name, "<anonymous>", 512),
|
|
511
|
+
...(source === undefined ? {} : { sourcePath: source }),
|
|
512
|
+
line: integerOr(raw.line, 0),
|
|
513
|
+
column: integerOr(raw.column, 0),
|
|
514
|
+
}];
|
|
515
|
+
});
|
|
516
|
+
const body = isRecord(response.body) ? response.body : {};
|
|
517
|
+
const totalFrames = Number.isInteger(body.totalFrames)
|
|
518
|
+
? Math.min(64, Math.max(0, Number(body.totalFrames)))
|
|
519
|
+
: Math.min(64, offset + rawFrames.length);
|
|
520
|
+
return { frames, totalFrames };
|
|
521
|
+
}
|
|
522
|
+
async scopeReference(debuggerClient, frameId, scope, stopSequence) {
|
|
523
|
+
const response = await debuggerClient.request("scopes", { frameId });
|
|
524
|
+
this.bindDebugStop(stopSequence);
|
|
525
|
+
const match = bodyArray(response, "scopes").find((entry) => isRecord(entry) && typeof entry.name === "string" && entry.name.toLowerCase() === scope);
|
|
526
|
+
if (!isRecord(match) || !Number.isInteger(match.variablesReference) || Number(match.variablesReference) < 1) {
|
|
527
|
+
throw runtimeError("PRECONDITION_FAILED", `Godot debugger did not return the ${scope} scope`);
|
|
528
|
+
}
|
|
529
|
+
return Number(match.variablesReference);
|
|
530
|
+
}
|
|
531
|
+
async readVariables(debuggerClient, variablesReference, depth, offset, limit, stopSequence) {
|
|
532
|
+
if (depth > 8)
|
|
533
|
+
throw runtimeError("PRECONDITION_FAILED", "Debugger variable depth limit exceeded");
|
|
534
|
+
const response = await this.requestVariables(debuggerClient, { variablesReference, start: offset, count: limit });
|
|
535
|
+
this.bindDebugStop(stopSequence);
|
|
536
|
+
const all = bodyArray(response, "variables");
|
|
537
|
+
const selected = all.slice(0, limit);
|
|
538
|
+
const body = isRecord(response.body) ? response.body : {};
|
|
539
|
+
const total = Number.isInteger(body.totalVariables)
|
|
540
|
+
? Math.min(2_048, Math.max(0, Number(body.totalVariables)))
|
|
541
|
+
: Math.min(2_048, offset + all.length);
|
|
542
|
+
this.debugTokens.consumeVariableEntries(selected.length);
|
|
543
|
+
return {
|
|
544
|
+
variables: selected.map((entry) => this.formatVariable(entry, depth)),
|
|
545
|
+
offset,
|
|
546
|
+
returned: selected.length,
|
|
547
|
+
total,
|
|
548
|
+
truncated: body.truncated === true || all.length > selected.length || offset + selected.length < total,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
formatVariable(rawValue, depth) {
|
|
552
|
+
const raw = isRecord(rawValue) ? rawValue : {};
|
|
553
|
+
const childReference = Number.isInteger(raw.variablesReference) ? Number(raw.variablesReference) : 0;
|
|
554
|
+
const value = truncateUtf8(typeof raw.value === "string" ? raw.value : String(raw.value ?? ""), 4_096);
|
|
555
|
+
const variableToken = childReference > 0 && depth < 8
|
|
556
|
+
? this.debugTokens.issueVariable(childReference, depth)
|
|
557
|
+
: undefined;
|
|
558
|
+
const selectorMetadata = { selectorKind: "unsupported" };
|
|
559
|
+
if (raw.selectorKind === "string" &&
|
|
560
|
+
typeof raw.selectorValue === "string" &&
|
|
561
|
+
raw.selectorValue.length > 0 &&
|
|
562
|
+
!raw.selectorValue.includes("\0") &&
|
|
563
|
+
Buffer.byteLength(raw.selectorValue, "utf8") <= 128) {
|
|
564
|
+
selectorMetadata.selectorKind = "string";
|
|
565
|
+
selectorMetadata.selectorValue = raw.selectorValue;
|
|
566
|
+
}
|
|
567
|
+
else if (raw.selectorKind === "number" &&
|
|
568
|
+
Number.isInteger(raw.selectorValue) &&
|
|
569
|
+
Number(raw.selectorValue) >= 0 &&
|
|
570
|
+
Number(raw.selectorValue) <= 1_000_000) {
|
|
571
|
+
selectorMetadata.selectorKind = "number";
|
|
572
|
+
selectorMetadata.selectorValue = Number(raw.selectorValue);
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
name: boundedText(raw.name, "<unnamed>", 128),
|
|
576
|
+
type: boundedText(raw.type, "unknown", 128),
|
|
577
|
+
value: value.text,
|
|
578
|
+
valueTruncated: raw.valueTruncated === true || value.truncated,
|
|
579
|
+
hasChildren: childReference > 0,
|
|
580
|
+
expandable: variableToken !== undefined,
|
|
581
|
+
...selectorMetadata,
|
|
582
|
+
...(variableToken === undefined ? {} : { variableToken }),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
async resolveWatch(debuggerClient, frameId, selector, stopSequence) {
|
|
586
|
+
let reference = await this.scopeReference(debuggerClient, frameId, selector.scope, stopSequence);
|
|
587
|
+
let current;
|
|
588
|
+
for (const [depthIndex, segment] of selector.path.entries()) {
|
|
589
|
+
const response = await this.requestVariables(debuggerClient, { variablesReference: reference, start: 0, count: 256 });
|
|
590
|
+
this.bindDebugStop(stopSequence);
|
|
591
|
+
const all = bodyArray(response, "variables");
|
|
592
|
+
const bounded = all.slice(0, 256);
|
|
593
|
+
this.debugTokens.consumeVariableEntries(bounded.length);
|
|
594
|
+
current = bounded.find((entry) => {
|
|
595
|
+
if (!isRecord(entry))
|
|
596
|
+
return false;
|
|
597
|
+
const segmentKind = typeof segment;
|
|
598
|
+
if (entry.selectorKind === segmentKind)
|
|
599
|
+
return entry.selectorValue === segment;
|
|
600
|
+
return entry.selectorKind === undefined && segmentKind === "string" && entry.name === segment;
|
|
601
|
+
});
|
|
602
|
+
const body = isRecord(response.body) ? response.body : {};
|
|
603
|
+
if (!current)
|
|
604
|
+
return { selector, status: body.truncated === true || all.length > bounded.length ? "truncated" : "missing" };
|
|
605
|
+
if (depthIndex === selector.path.length - 1) {
|
|
606
|
+
return { selector, status: "found", variable: this.formatVariable(current, depthIndex + 1) };
|
|
607
|
+
}
|
|
608
|
+
if (!isRecord(current) || !Number.isInteger(current.variablesReference) || Number(current.variablesReference) < 1) {
|
|
609
|
+
return { selector, status: "missing" };
|
|
610
|
+
}
|
|
611
|
+
reference = Number(current.variablesReference);
|
|
612
|
+
}
|
|
613
|
+
return { selector, status: "missing" };
|
|
614
|
+
}
|
|
615
|
+
async requestVariables(debuggerClient, argumentsValue) {
|
|
616
|
+
return debuggerClient.request("variables", argumentsValue);
|
|
617
|
+
}
|
|
618
|
+
projectSourcePath(absolutePath) {
|
|
619
|
+
if (absolutePath.startsWith("res://")) {
|
|
620
|
+
if (absolutePath.toLowerCase().startsWith("res://addons/godot_mcp/"))
|
|
621
|
+
return undefined;
|
|
622
|
+
return absolutePath;
|
|
623
|
+
}
|
|
624
|
+
const projectRelative = relative(this.dependencies.project.rootRealPath, absolutePath);
|
|
625
|
+
if (projectRelative === "" || projectRelative === ".." || projectRelative.startsWith(`..${sep}`) || isAbsolute(projectRelative))
|
|
626
|
+
return undefined;
|
|
627
|
+
return `res://${projectRelative.split(sep).join("/")}`;
|
|
628
|
+
}
|
|
629
|
+
capture(input) {
|
|
630
|
+
return this.runExclusive(() => this.captureExclusive(input));
|
|
631
|
+
}
|
|
632
|
+
input(input) {
|
|
633
|
+
return this.runExclusive(() => this.inputExclusive(input));
|
|
634
|
+
}
|
|
635
|
+
async inputExclusive(input) {
|
|
636
|
+
this.assertHandle(input.handle);
|
|
637
|
+
const timeoutMs = input.operation === "sequence" || input.operation === "replay"
|
|
638
|
+
? input.timeoutMs + 1_000
|
|
639
|
+
: undefined;
|
|
640
|
+
const raw = await this.dependencies.command("input", { handle: input.handle, input }, timeoutMs);
|
|
641
|
+
const result = InputOperationResultSchema.parse(raw);
|
|
642
|
+
if (result.receipt.handle.runId !== input.handle.runId ||
|
|
643
|
+
result.receipt.handle.generation !== input.handle.generation ||
|
|
644
|
+
result.receipt.operation !== input.operation)
|
|
645
|
+
throw runtimeError("GODOT_RUNTIME_ERROR", "Runtime input receipt identity does not match the request");
|
|
646
|
+
const events = input.operation === "record_stop" ? result.trace.events : inputTraceEvents(input);
|
|
647
|
+
const expectedDeterministic = input.operation === "replay" || (input.operation === "sequence" && input.mode === "deterministic");
|
|
648
|
+
const recordingMismatch = input.operation === "record_start"
|
|
649
|
+
? !result.receipt.recording
|
|
650
|
+
: input.operation === "record_stop" && result.receipt.recording;
|
|
651
|
+
const eventReceiptsMatch = result.receipt.events.every((eventReceipt, index) => {
|
|
652
|
+
const event = events[index];
|
|
653
|
+
return event !== undefined &&
|
|
654
|
+
eventReceipt.index === index &&
|
|
655
|
+
eventReceipt.kind === event.event.type &&
|
|
656
|
+
eventReceipt.scheduledFrame === event.frameOffset;
|
|
657
|
+
});
|
|
658
|
+
if (result.receipt.eventCount !== events.length ||
|
|
659
|
+
result.receipt.deliveredCount !== events.length ||
|
|
660
|
+
result.receipt.events.length !== events.length ||
|
|
661
|
+
result.receipt.deterministic !== expectedDeterministic ||
|
|
662
|
+
recordingMismatch ||
|
|
663
|
+
result.receipt.traceSha256 !== traceSha256({ schemaVersion: 1, events }) ||
|
|
664
|
+
!eventReceiptsMatch)
|
|
665
|
+
throw runtimeError("GODOT_RUNTIME_ERROR", "Runtime input receipt does not match the requested trace");
|
|
666
|
+
return result;
|
|
667
|
+
}
|
|
668
|
+
async captureExclusive(input) {
|
|
669
|
+
this.assertHandle(input.handle);
|
|
670
|
+
if (!this.dependencies.capture)
|
|
671
|
+
throw runtimeError("GODOT_RUNTIME_ERROR", "Runtime capture transport is unavailable");
|
|
672
|
+
const frames = [];
|
|
673
|
+
for (let frameIndex = 0; frameIndex < input.frameCount; frameIndex += 1) {
|
|
674
|
+
const response = await this.dependencies.capture({
|
|
675
|
+
operation: "capture",
|
|
676
|
+
handle: input.handle,
|
|
677
|
+
maxWidth: input.maxWidth,
|
|
678
|
+
maxHeight: input.maxHeight,
|
|
679
|
+
frameIndex,
|
|
680
|
+
waitFrames: frameIndex === 0 ? 0 : input.intervalFrames,
|
|
681
|
+
advancePaused: input.advancePaused,
|
|
682
|
+
}, 15_000);
|
|
683
|
+
if (!response.binary || response.binarySha256 !== response.data.sha256) {
|
|
684
|
+
throw runtimeError("GODOT_RUNTIME_ERROR", "Runtime capture omitted verified PNG bytes");
|
|
685
|
+
}
|
|
686
|
+
frames.push({ data: response.binary, metadata: response.data });
|
|
687
|
+
}
|
|
688
|
+
return { frames };
|
|
689
|
+
}
|
|
690
|
+
close() {
|
|
691
|
+
this.closed = true;
|
|
692
|
+
if (!this.closePromise) {
|
|
693
|
+
const activeClose = this.disconnect();
|
|
694
|
+
this.closePromise = activeClose;
|
|
695
|
+
void activeClose.catch(() => {
|
|
696
|
+
if (this.closePromise === activeClose)
|
|
697
|
+
this.closePromise = undefined;
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
return this.closePromise;
|
|
701
|
+
}
|
|
702
|
+
disconnect() {
|
|
703
|
+
if (this.disconnectPromise)
|
|
704
|
+
return this.disconnectPromise;
|
|
705
|
+
this.lifecycleEpoch += 1;
|
|
706
|
+
const activeDisconnect = (async () => {
|
|
707
|
+
await this.launchPromise?.catch(() => undefined);
|
|
708
|
+
await this.runExclusive(() => this.cleanup());
|
|
709
|
+
})();
|
|
710
|
+
this.disconnectPromise = activeDisconnect;
|
|
711
|
+
void activeDisconnect.finally(() => {
|
|
712
|
+
if (this.disconnectPromise === activeDisconnect)
|
|
713
|
+
this.disconnectPromise = undefined;
|
|
714
|
+
}).catch(() => undefined);
|
|
715
|
+
return activeDisconnect;
|
|
716
|
+
}
|
|
717
|
+
assertHandle(handle) {
|
|
718
|
+
this.assertHandleIdentity(handle);
|
|
719
|
+
if (!["running", "paused"].includes(this.state))
|
|
720
|
+
throw runtimeError("CONFLICT", "Runtime is not controllable in its current state");
|
|
721
|
+
}
|
|
722
|
+
assertHandleIdentity(handle) {
|
|
723
|
+
if (!this.handle || handle.runId !== this.handle.runId || handle.generation !== this.handle.generation) {
|
|
724
|
+
throw runtimeError("STALE_HANDLE", "Runtime handle is stale");
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
runExclusive(operation) {
|
|
728
|
+
const result = this.operationTail.then(operation);
|
|
729
|
+
this.operationTail = result.then(() => undefined, () => undefined);
|
|
730
|
+
return result;
|
|
731
|
+
}
|
|
732
|
+
assertLaunchCurrent(epoch) {
|
|
733
|
+
if (this.closed || epoch !== this.lifecycleEpoch) {
|
|
734
|
+
throw runtimeError("CONFLICT", "Runtime launch was cancelled by shutdown");
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
async stopProcess() {
|
|
738
|
+
if (!this.process || this.processStopped)
|
|
739
|
+
return;
|
|
740
|
+
const ownedProcess = this.process;
|
|
741
|
+
this.processStopped = true;
|
|
742
|
+
try {
|
|
743
|
+
await ownedProcess.stop();
|
|
744
|
+
}
|
|
745
|
+
catch (error) {
|
|
746
|
+
if (this.process === ownedProcess)
|
|
747
|
+
this.processStopped = false;
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
async cleanup() {
|
|
752
|
+
if (this.cleanupPromise)
|
|
753
|
+
return this.cleanupPromise;
|
|
754
|
+
const activeCleanup = (async () => {
|
|
755
|
+
this.opaqueProfileId = null;
|
|
756
|
+
this.debuggerSessionId = null;
|
|
757
|
+
let cleanupError;
|
|
758
|
+
try {
|
|
759
|
+
await this.cleanupDebuggerClient();
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
cleanupError = error;
|
|
763
|
+
}
|
|
764
|
+
if (this.descriptor) {
|
|
765
|
+
try {
|
|
766
|
+
await this.descriptor.cleanup();
|
|
767
|
+
this.descriptor = null;
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
cleanupError ??= error;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
try {
|
|
774
|
+
await this.stopProcess();
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
cleanupError ??= error;
|
|
778
|
+
}
|
|
779
|
+
await this.cleanupDebugger(false);
|
|
780
|
+
if (cleanupError) {
|
|
781
|
+
this.state = "failed";
|
|
782
|
+
throw cleanupError;
|
|
783
|
+
}
|
|
784
|
+
if (!["stopped", "idle"].includes(this.state))
|
|
785
|
+
this.state = "stopped";
|
|
786
|
+
})();
|
|
787
|
+
this.cleanupPromise = activeCleanup;
|
|
788
|
+
try {
|
|
789
|
+
await activeCleanup;
|
|
790
|
+
}
|
|
791
|
+
finally {
|
|
792
|
+
if (this.cleanupPromise === activeCleanup)
|
|
793
|
+
this.cleanupPromise = undefined;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
async cleanupDebuggerClient() {
|
|
797
|
+
const debuggerClient = this.debuggerClient;
|
|
798
|
+
this.debuggerClient = null;
|
|
799
|
+
this.debugTokens.clear();
|
|
800
|
+
const sources = [...this.breakpointSources.keys()];
|
|
801
|
+
this.breakpointSources.clear();
|
|
802
|
+
if (!debuggerClient)
|
|
803
|
+
return;
|
|
804
|
+
let cleanupError;
|
|
805
|
+
for (const absolutePath of sources) {
|
|
806
|
+
try {
|
|
807
|
+
await debuggerClient.request("setBreakpoints", {
|
|
808
|
+
source: { name: absolutePath.split(sep).at(-1), path: absolutePath },
|
|
809
|
+
breakpoints: [],
|
|
810
|
+
}, 2_000);
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
if (!isTerminalDebuggerCleanupError(error))
|
|
814
|
+
cleanupError ??= error;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
await debuggerClient.request("disconnect", { terminateDebuggee: false }, 2_000);
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
if (!isTerminalDebuggerCleanupError(error))
|
|
822
|
+
cleanupError ??= error;
|
|
823
|
+
}
|
|
824
|
+
await debuggerClient.close().catch((error) => { cleanupError ??= error; });
|
|
825
|
+
if (cleanupError)
|
|
826
|
+
throw cleanupError;
|
|
827
|
+
}
|
|
828
|
+
async cleanupDebugger(required) {
|
|
829
|
+
if (this.debuggerCleaned)
|
|
830
|
+
return;
|
|
831
|
+
if (!this.dependencies.cleanup) {
|
|
832
|
+
this.debuggerCleaned = true;
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
await this.dependencies.cleanup();
|
|
837
|
+
this.debuggerCleaned = true;
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
if (required)
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
onProcessExit(process) {
|
|
845
|
+
if (this.process !== process || this.processStopped)
|
|
846
|
+
return;
|
|
847
|
+
this.processStopped = true;
|
|
848
|
+
void this.runExclusive(() => this.cleanup()).catch(() => undefined);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
function isRecord(value) {
|
|
852
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
853
|
+
}
|
|
854
|
+
function bodyArray(response, key) {
|
|
855
|
+
if (!isRecord(response.body))
|
|
856
|
+
return [];
|
|
857
|
+
const value = response.body[key];
|
|
858
|
+
return Array.isArray(value) ? value : [];
|
|
859
|
+
}
|
|
860
|
+
function integerOr(value, fallback) {
|
|
861
|
+
return Number.isInteger(value) ? Number(value) : fallback;
|
|
862
|
+
}
|
|
863
|
+
function isPathInsideCaseInsensitive(parent, candidate) {
|
|
864
|
+
const relation = relative(parent.toLowerCase(), candidate.toLowerCase());
|
|
865
|
+
return relation === "" || (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation));
|
|
866
|
+
}
|
|
867
|
+
function boundedText(value, fallback, maxLength) {
|
|
868
|
+
const text = typeof value === "string" ? value : fallback;
|
|
869
|
+
return text.slice(0, maxLength);
|
|
870
|
+
}
|
|
871
|
+
function truncateUtf8(text, maxBytes) {
|
|
872
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes)
|
|
873
|
+
return { text, truncated: false };
|
|
874
|
+
let result = "";
|
|
875
|
+
let bytes = 0;
|
|
876
|
+
for (const character of text) {
|
|
877
|
+
const characterBytes = Buffer.byteLength(character, "utf8");
|
|
878
|
+
if (bytes + characterBytes > maxBytes)
|
|
879
|
+
break;
|
|
880
|
+
result += character;
|
|
881
|
+
bytes += characterBytes;
|
|
882
|
+
}
|
|
883
|
+
return { text: result, truncated: true };
|
|
884
|
+
}
|
|
885
|
+
function projectDebugStop(event) {
|
|
886
|
+
const rawReason = event.reason.toLowerCase();
|
|
887
|
+
const reason = rawReason.includes("breakpoint")
|
|
888
|
+
? "breakpoint"
|
|
889
|
+
: rawReason.includes("exception")
|
|
890
|
+
? "exception"
|
|
891
|
+
: rawReason.includes("step")
|
|
892
|
+
? "step"
|
|
893
|
+
: rawReason.includes("pause")
|
|
894
|
+
? "pause"
|
|
895
|
+
: "unknown";
|
|
896
|
+
return DebugStopResultSchema.parse({ sequence: event.sequence, reason });
|
|
897
|
+
}
|
|
898
|
+
function normalizeDebugError(error) {
|
|
899
|
+
if (error instanceof GodotMcpException)
|
|
900
|
+
return error;
|
|
901
|
+
if (error instanceof DebugTokenStoreError)
|
|
902
|
+
return runtimeError("STALE_HANDLE", error.message);
|
|
903
|
+
if (error instanceof DebuggerClientError) {
|
|
904
|
+
switch (error.code) {
|
|
905
|
+
case "INVALID_REQUEST":
|
|
906
|
+
return runtimeError("INVALID_REQUEST", error.message);
|
|
907
|
+
case "TIMEOUT":
|
|
908
|
+
return runtimeError("TIMEOUT", error.message, true);
|
|
909
|
+
case "TRANSPORT_ERROR":
|
|
910
|
+
return runtimeError("TRANSPORT_ERROR", error.message, true);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
914
|
+
}
|
|
915
|
+
function isTerminalDebuggerCleanupError(error) {
|
|
916
|
+
return (error instanceof GodotMcpException && (error.code === "NOT_ATTACHED" || error.code === "TRANSPORT_ERROR")) ||
|
|
917
|
+
(error instanceof DebuggerClientError && error.code === "TRANSPORT_ERROR");
|
|
918
|
+
}
|
|
919
|
+
//# sourceMappingURL=runtimeService.js.map
|