breakpoint-mcp 1.0.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 +85 -0
- package/dist/bridge.js +189 -0
- package/dist/config.js +71 -0
- package/dist/confirm.js +45 -0
- package/dist/csdap.js +331 -0
- package/dist/cslsp.js +217 -0
- package/dist/dap.js +341 -0
- package/dist/framing.js +125 -0
- package/dist/index.js +110 -0
- package/dist/logger.js +11 -0
- package/dist/lsp.js +219 -0
- package/dist/paths.js +28 -0
- package/dist/schemas.js +587 -0
- package/dist/stdio.js +101 -0
- package/dist/subscriptions.js +141 -0
- package/dist/tasks.js +146 -0
- package/dist/tools/assetgen.js +360 -0
- package/dist/tools/backend.js +532 -0
- package/dist/tools/cli.js +130 -0
- package/dist/tools/csdap.js +377 -0
- package/dist/tools/cslsp.js +285 -0
- package/dist/tools/dap.js +504 -0
- package/dist/tools/editor.js +1919 -0
- package/dist/tools/knowledge.js +517 -0
- package/dist/tools/lsp-common.js +121 -0
- package/dist/tools/lsp.js +576 -0
- package/dist/tools/netcode.js +411 -0
- package/dist/tools/processes.js +103 -0
- package/dist/tools/resources.js +27 -0
- package/dist/tools/runtime.js +125 -0
- package/package.json +57 -0
package/dist/stdio.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { FrameDecoder, encodeFrame } from "./framing.js";
|
|
3
|
+
import { log } from "./logger.js";
|
|
4
|
+
/**
|
|
5
|
+
* A `JsonRpcChannel` backed by a spawned subprocess speaking LSP over stdio —
|
|
6
|
+
* the transport OmniSharp (and other CLI language servers) use, unlike Godot's
|
|
7
|
+
* TCP LSP. Frames are `Content-Length`-delimited on the child's stdin/stdout
|
|
8
|
+
* (reusing `FrameDecoder`/`encodeFrame`), so a protocol client written against
|
|
9
|
+
* `JsonRpcChannel` works over stdio exactly as it does over TCP.
|
|
10
|
+
*
|
|
11
|
+
* The process is spawned LAZILY on the first `send()` — like `FramedConnection`
|
|
12
|
+
* connects lazily — so a host with no C# language server installed pays nothing
|
|
13
|
+
* and never fails at startup; only a cs_* tool call actually launches it. A spawn
|
|
14
|
+
* failure (e.g. the binary isn't on PATH) rejects the send with an actionable
|
|
15
|
+
* hint rather than hanging.
|
|
16
|
+
*/
|
|
17
|
+
export class StdioChannel {
|
|
18
|
+
command;
|
|
19
|
+
args;
|
|
20
|
+
cwd;
|
|
21
|
+
label;
|
|
22
|
+
unavailableHint;
|
|
23
|
+
child = null;
|
|
24
|
+
starting = null;
|
|
25
|
+
decoder;
|
|
26
|
+
messageCb = () => { };
|
|
27
|
+
closeCb = () => { };
|
|
28
|
+
constructor(command, args, cwd, label, unavailableHint) {
|
|
29
|
+
this.command = command;
|
|
30
|
+
this.args = args;
|
|
31
|
+
this.cwd = cwd;
|
|
32
|
+
this.label = label;
|
|
33
|
+
this.unavailableHint = unavailableHint;
|
|
34
|
+
this.decoder = new FrameDecoder((m) => this.messageCb(m), label);
|
|
35
|
+
}
|
|
36
|
+
onMessage(cb) {
|
|
37
|
+
this.messageCb = cb;
|
|
38
|
+
}
|
|
39
|
+
onClose(cb) {
|
|
40
|
+
this.closeCb = cb;
|
|
41
|
+
}
|
|
42
|
+
start() {
|
|
43
|
+
if (this.child && this.child.exitCode === null && !this.child.killed)
|
|
44
|
+
return Promise.resolve(this.child);
|
|
45
|
+
if (this.starting)
|
|
46
|
+
return this.starting;
|
|
47
|
+
this.starting = new Promise((resolve, reject) => {
|
|
48
|
+
let child;
|
|
49
|
+
try {
|
|
50
|
+
child = spawn(this.command, this.args, { cwd: this.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
this.starting = null;
|
|
54
|
+
reject(new Error(`${this.label} could not spawn '${this.command}'. ${this.unavailableHint} (${err.message})`));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
child.once("spawn", () => {
|
|
58
|
+
this.child = child;
|
|
59
|
+
this.starting = null;
|
|
60
|
+
log(`${this.label} spawned '${this.command} ${this.args.join(" ")}' (cwd=${this.cwd})`);
|
|
61
|
+
resolve(child);
|
|
62
|
+
});
|
|
63
|
+
child.once("error", (err) => {
|
|
64
|
+
this.starting = null;
|
|
65
|
+
this.child = null;
|
|
66
|
+
reject(new Error(`${this.label} could not spawn '${this.command}'. ${this.unavailableHint} (${err.message})`));
|
|
67
|
+
});
|
|
68
|
+
child.stdout.on("data", (chunk) => this.decoder.push(chunk));
|
|
69
|
+
// The server's own logging goes to stderr (LSP frames are stdout-only); surface
|
|
70
|
+
// it in the host log, trimmed, so a misbehaving server is diagnosable.
|
|
71
|
+
child.stderr.on("data", (chunk) => {
|
|
72
|
+
const text = chunk.toString("utf8").trimEnd();
|
|
73
|
+
if (text)
|
|
74
|
+
log(`${this.label} stderr: ${text.length > 500 ? text.slice(0, 500) + "…" : text}`);
|
|
75
|
+
});
|
|
76
|
+
child.on("exit", (code, signal) => {
|
|
77
|
+
log(`${this.label} exited (code=${code ?? "null"} signal=${signal ?? "null"})`);
|
|
78
|
+
this.child = null;
|
|
79
|
+
this.decoder.reset();
|
|
80
|
+
this.closeCb();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
return this.starting;
|
|
84
|
+
}
|
|
85
|
+
async send(msg) {
|
|
86
|
+
const child = await this.start();
|
|
87
|
+
child.stdin.write(encodeFrame(msg));
|
|
88
|
+
}
|
|
89
|
+
close() {
|
|
90
|
+
if (this.child) {
|
|
91
|
+
try {
|
|
92
|
+
this.child.kill();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* already gone */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
this.child = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=stdio.js.map
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { log } from "./logger.js";
|
|
3
|
+
/**
|
|
4
|
+
* D3 — resource subscriptions.
|
|
5
|
+
*
|
|
6
|
+
* Adds the MCP resources/subscribe + resources/unsubscribe requests and pushes
|
|
7
|
+
* notifications/resources/updated when a subscribed `godot://…` resource
|
|
8
|
+
* changes. The change signal originates in the editor addon (selection / edited
|
|
9
|
+
* scene changed) or the in-game runtime autoload (live SceneTree changed),
|
|
10
|
+
* travels over the same bridge socket D2's request path uses as an unsolicited
|
|
11
|
+
* "resource.changed" event, and is fanned out here — but only for URIs a client
|
|
12
|
+
* has actually subscribed to. Non-subscribers keep the unchanged pull-only
|
|
13
|
+
* behavior.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Default trailing window (ms) for coalescing rapid resource.changed events into
|
|
17
|
+
* fewer notifications/resources/updated. Override via BREAKPOINT_RESOURCE_COALESCE_MS.
|
|
18
|
+
* Multiple `updated` are spec-harmless (the client just re-reads), so this only
|
|
19
|
+
* trims volume; 0 disables coalescing entirely.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_COALESCE_MS = 50;
|
|
22
|
+
/**
|
|
23
|
+
* Server capability advertising resources/subscribe (and, by extension, the
|
|
24
|
+
* notifications/resources/updated the SDK gates on `capabilities.resources`).
|
|
25
|
+
* Merged with the task capabilities in index.ts.
|
|
26
|
+
*/
|
|
27
|
+
export const RESOURCE_CAPABILITIES = {
|
|
28
|
+
resources: { subscribe: true },
|
|
29
|
+
};
|
|
30
|
+
/** Tracks which `godot://…` resource URIs the connected client is subscribed to. */
|
|
31
|
+
export class ResourceSubscriptions {
|
|
32
|
+
uris = new Set();
|
|
33
|
+
subscribe(uri) {
|
|
34
|
+
this.uris.add(uri);
|
|
35
|
+
}
|
|
36
|
+
unsubscribe(uri) {
|
|
37
|
+
this.uris.delete(uri);
|
|
38
|
+
}
|
|
39
|
+
has(uri) {
|
|
40
|
+
return this.uris.has(uri);
|
|
41
|
+
}
|
|
42
|
+
get size() {
|
|
43
|
+
return this.uris.size;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Runtime-bridge resources live under godot://runtime/; the rest are editor-served. */
|
|
47
|
+
function isRuntimeUri(uri) {
|
|
48
|
+
return uri.startsWith("godot://runtime/");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Install resources/subscribe + resources/unsubscribe on the low-level server,
|
|
52
|
+
* hold the relevant bridge connected so the addon's push events flow, and
|
|
53
|
+
* forward each "resource.changed" event to notifications/resources/updated for
|
|
54
|
+
* exactly the subscribed URIs.
|
|
55
|
+
*
|
|
56
|
+
* Rapid changes are coalesced per-URI with a leading-edge + trailing-flush
|
|
57
|
+
* throttle: the first change pushes immediately (responsive), then further
|
|
58
|
+
* changes inside a `coalesceMs` window collapse into at most one trailing push.
|
|
59
|
+
* This keeps a burst — e.g. many SceneTree mutations in one frame — from fanning
|
|
60
|
+
* out as a flood of notifications. Set coalesceMs to 0 to disable.
|
|
61
|
+
*
|
|
62
|
+
* The subscribe/unsubscribe methods are not capability-gated by the SDK's
|
|
63
|
+
* request-handler assertion, but sendResourceUpdated is gated on the `resources`
|
|
64
|
+
* capability — hence RESOURCE_CAPABILITIES must be advertised at construction.
|
|
65
|
+
*/
|
|
66
|
+
export function registerResourceSubscriptions(server, editor, runtime, subs = new ResourceSubscriptions(), opts = {}) {
|
|
67
|
+
const low = server.server;
|
|
68
|
+
const coalesceMs = opts.coalesceMs ?? DEFAULT_COALESCE_MS;
|
|
69
|
+
low.setRequestHandler(SubscribeRequestSchema, async (request) => {
|
|
70
|
+
const uri = request.params.uri;
|
|
71
|
+
subs.subscribe(uri);
|
|
72
|
+
// Keep the push channel open so the addon can deliver change events even if
|
|
73
|
+
// the host never issued a pull request for this resource.
|
|
74
|
+
const source = isRuntimeUri(uri) ? runtime : editor;
|
|
75
|
+
void source.ensureConnected();
|
|
76
|
+
log(`resources/subscribe ${uri} (${subs.size} active)`);
|
|
77
|
+
return {};
|
|
78
|
+
});
|
|
79
|
+
const throttles = new Map();
|
|
80
|
+
const push = (uri) => {
|
|
81
|
+
void low.sendResourceUpdated({ uri }).catch((err) => {
|
|
82
|
+
log(`sendResourceUpdated ${uri} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const clearThrottle = (uri) => {
|
|
86
|
+
const t = throttles.get(uri);
|
|
87
|
+
if (t) {
|
|
88
|
+
clearTimeout(t.timer);
|
|
89
|
+
throttles.delete(uri);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const arm = (uri) => {
|
|
93
|
+
const timer = setTimeout(() => onWindowEnd(uri), coalesceMs);
|
|
94
|
+
// Never keep the event loop alive just for a coalescing window.
|
|
95
|
+
timer.unref?.();
|
|
96
|
+
throttles.set(uri, { timer, pending: false });
|
|
97
|
+
};
|
|
98
|
+
const onWindowEnd = (uri) => {
|
|
99
|
+
const t = throttles.get(uri);
|
|
100
|
+
if (!t)
|
|
101
|
+
return;
|
|
102
|
+
if (t.pending && subs.has(uri)) {
|
|
103
|
+
// A change landed during the window — flush it and hold the window open so
|
|
104
|
+
// a sustained stream stays capped at ~one push per window instead of starving.
|
|
105
|
+
push(uri);
|
|
106
|
+
arm(uri);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
throttles.delete(uri);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const forward = (uri) => {
|
|
113
|
+
if (!subs.has(uri))
|
|
114
|
+
return;
|
|
115
|
+
if (coalesceMs <= 0) {
|
|
116
|
+
push(uri);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const t = throttles.get(uri);
|
|
120
|
+
if (t) {
|
|
121
|
+
// Inside an open window — collapse into the eventual trailing flush.
|
|
122
|
+
t.pending = true;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// Leading edge — push now and open a coalescing window.
|
|
126
|
+
push(uri);
|
|
127
|
+
arm(uri);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
low.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
|
|
131
|
+
const uri = request.params.uri;
|
|
132
|
+
subs.unsubscribe(uri);
|
|
133
|
+
clearThrottle(uri);
|
|
134
|
+
log(`resources/unsubscribe ${uri} (${subs.size} active)`);
|
|
135
|
+
return {};
|
|
136
|
+
});
|
|
137
|
+
editor.onResourceChanged(forward);
|
|
138
|
+
runtime.onResourceChanged(forward);
|
|
139
|
+
return subs;
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=subscriptions.js.map
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { InMemoryTaskStore, isTerminal } from "@modelcontextprotocol/sdk/experimental/tasks";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { outputSchemas } from "./schemas.js";
|
|
4
|
+
import { log } from "./logger.js";
|
|
5
|
+
/**
|
|
6
|
+
* D2 — the formal MCP task-execution model for long-running Godot jobs.
|
|
7
|
+
*
|
|
8
|
+
* Long, run-to-completion tools (headless export / import / script runs) used to
|
|
9
|
+
* emit ad-hoc `notifications/progress`. They now register under the spec's task
|
|
10
|
+
* model: a call creates a task, returns a handle immediately, and the client
|
|
11
|
+
* drives it with tasks/get (poll), tasks/result (await), and tasks/cancel
|
|
12
|
+
* (stop). Plain, non-task clients are unaffected — the SDK auto-creates a task,
|
|
13
|
+
* polls it to completion, and returns the result synchronously.
|
|
14
|
+
*/
|
|
15
|
+
/** How long a finished task's status + result stay retrievable (ms). */
|
|
16
|
+
export const TASK_TTL_MS = 15 * 60 * 1000;
|
|
17
|
+
/** Default poll cadence (ms); also the added latency of the synchronous path. */
|
|
18
|
+
export const TASK_POLL_INTERVAL_MS = 500;
|
|
19
|
+
/**
|
|
20
|
+
* Server capabilities advertising the task-execution model for tools/call.
|
|
21
|
+
* Passed to the McpServer constructor so the SDK installs the tasks/get,
|
|
22
|
+
* tasks/result, tasks/list and tasks/cancel request handlers.
|
|
23
|
+
*/
|
|
24
|
+
export const TASK_CAPABILITIES = {
|
|
25
|
+
tasks: { requests: { tools: { call: {} } } },
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* A TaskStore whose cancellation actually STOPS the running job.
|
|
29
|
+
*
|
|
30
|
+
* The SDK's Protocol handles `tasks/cancel` by calling `updateTaskStatus(id,
|
|
31
|
+
* 'cancelled')` on this store — which only records the status, it does not
|
|
32
|
+
* interrupt the in-flight work. We keep a per-task AbortController and trip it
|
|
33
|
+
* the instant a task transitions to 'cancelled', so a headless Godot run is
|
|
34
|
+
* really killed rather than left orphaned.
|
|
35
|
+
*/
|
|
36
|
+
export class GodotTaskStore extends InMemoryTaskStore {
|
|
37
|
+
aborters = new Map();
|
|
38
|
+
/** Bind a running job's AbortController to its task id (call in createTask). */
|
|
39
|
+
registerJob(taskId, controller) {
|
|
40
|
+
this.aborters.set(taskId, controller);
|
|
41
|
+
}
|
|
42
|
+
/** Test/introspection helper: is a job still tracked as cancellable? */
|
|
43
|
+
isTracked(taskId) {
|
|
44
|
+
return this.aborters.has(taskId);
|
|
45
|
+
}
|
|
46
|
+
async updateTaskStatus(taskId, status, statusMessage, sessionId) {
|
|
47
|
+
// Abort BEFORE recording the terminal status so the worker's own
|
|
48
|
+
// signal.aborted guard trips ahead of any late storeTaskResult().
|
|
49
|
+
if (status === "cancelled") {
|
|
50
|
+
const c = this.aborters.get(taskId);
|
|
51
|
+
if (c && !c.signal.aborted)
|
|
52
|
+
c.abort();
|
|
53
|
+
}
|
|
54
|
+
await super.updateTaskStatus(taskId, status, statusMessage, sessionId);
|
|
55
|
+
if (isTerminal(status))
|
|
56
|
+
this.aborters.delete(taskId);
|
|
57
|
+
}
|
|
58
|
+
async storeTaskResult(taskId, status, result, sessionId) {
|
|
59
|
+
await super.storeTaskResult(taskId, status, result, sessionId);
|
|
60
|
+
this.aborters.delete(taskId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The one store the server and every task tool share. */
|
|
64
|
+
export const taskStore = new GodotTaskStore();
|
|
65
|
+
function errorResult(message) {
|
|
66
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Wrap a plain worker into the SDK's { createTask, getTask, getTaskResult }
|
|
70
|
+
* ToolTaskHandler. createTask registers the task, launches the work in the
|
|
71
|
+
* background, and returns the handle immediately; the background settle stores
|
|
72
|
+
* the result — which the SDK turns into a notifications/tasks/status update and
|
|
73
|
+
* uses to satisfy any pending tasks/result long-poll.
|
|
74
|
+
*/
|
|
75
|
+
function makeTaskHandler(name, worker) {
|
|
76
|
+
const shape = outputSchemas[name];
|
|
77
|
+
const outSchema = shape ? z.object(shape) : undefined;
|
|
78
|
+
return {
|
|
79
|
+
createTask: async (args, extra) => {
|
|
80
|
+
const task = await extra.taskStore.createTask({
|
|
81
|
+
ttl: TASK_TTL_MS,
|
|
82
|
+
pollInterval: TASK_POLL_INTERVAL_MS,
|
|
83
|
+
});
|
|
84
|
+
const taskId = task.taskId;
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
taskStore.registerJob(taskId, controller);
|
|
87
|
+
void (async () => {
|
|
88
|
+
let settled;
|
|
89
|
+
try {
|
|
90
|
+
const result = await worker(args, controller.signal);
|
|
91
|
+
if (controller.signal.aborted)
|
|
92
|
+
return; // cancelled: store already terminal
|
|
93
|
+
// Preserve the B1 invariant the SDK skips for task results: a
|
|
94
|
+
// successful structured result must match its frozen output schema.
|
|
95
|
+
if (outSchema && !result.isError) {
|
|
96
|
+
if (!result.structuredContent) {
|
|
97
|
+
throw new Error(`tool ${name} produced no structuredContent`);
|
|
98
|
+
}
|
|
99
|
+
outSchema.parse(result.structuredContent);
|
|
100
|
+
}
|
|
101
|
+
settled = {
|
|
102
|
+
status: result.isError ? "failed" : "completed",
|
|
103
|
+
result: result,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (controller.signal.aborted)
|
|
108
|
+
return;
|
|
109
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
110
|
+
settled = { status: "failed", result: errorResult(`Error: ${msg}`) };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await extra.taskStore.storeTaskResult(taskId, settled.status, settled.result);
|
|
114
|
+
}
|
|
115
|
+
catch (storeErr) {
|
|
116
|
+
// Benign if the task was cancelled in the same tick (terminal guard).
|
|
117
|
+
const m = storeErr instanceof Error ? storeErr.message : String(storeErr);
|
|
118
|
+
log(`task ${taskId} (${name}) result store skipped: ${m}`);
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
return { task };
|
|
122
|
+
},
|
|
123
|
+
getTask: async (_args, extra) => extra.taskStore.getTask(extra.taskId),
|
|
124
|
+
getTaskResult: async (_args, extra) => extra.taskStore.getTaskResult(extra.taskId),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Register a long-running tool under the formal MCP task model.
|
|
129
|
+
*
|
|
130
|
+
* With `taskSupport: 'optional'`, task-aware clients get the full
|
|
131
|
+
* create -> poll -> await -> cancel lifecycle, while plain clients keep today's
|
|
132
|
+
* blocking semantics (the SDK auto-creates a task, polls to completion, and
|
|
133
|
+
* returns the result synchronously). The frozen output schema in schemas.ts is
|
|
134
|
+
* applied here, mirroring applyOutputSchemas' handling of registerTool.
|
|
135
|
+
*/
|
|
136
|
+
export function registerTaskTool(server, name, config, worker) {
|
|
137
|
+
const shape = outputSchemas[name];
|
|
138
|
+
server.experimental.tasks.registerToolTask(name, {
|
|
139
|
+
title: config.title,
|
|
140
|
+
description: config.description,
|
|
141
|
+
inputSchema: config.inputSchema,
|
|
142
|
+
...(shape ? { outputSchema: shape } : {}),
|
|
143
|
+
execution: { taskSupport: "optional" },
|
|
144
|
+
}, makeTaskHandler(name, worker));
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=tasks.js.map
|