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/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