breakpoint-mcp 1.28.0 → 1.72.7
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/README.md +1 -1
- package/addon/breakpoint_mcp/operations.gd +248 -13
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +54 -1
- package/dist/annotations.js +3 -3
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/tools.js +2 -2
- package/dist/csdap.js +88 -6
- package/dist/dap.js +172 -10
- package/dist/index.js +4 -4
- package/dist/path-cohort.js +177 -0
- package/dist/paths.js +111 -0
- package/dist/recipes.js +1 -1
- package/dist/schemas.js +49 -5
- package/dist/tools/assetgen.js +17 -1
- package/dist/tools/backend.js +18 -1
- package/dist/tools/cli.js +41 -4
- package/dist/tools/csdap.js +132 -8
- package/dist/tools/cslsp.js +110 -5
- package/dist/tools/dap.js +316 -59
- package/dist/tools/editor/audio.js +16 -2
- package/dist/tools/editor/common.js +39 -1
- package/dist/tools/editor/filesystem.js +23 -3
- package/dist/tools/editor/introspection.js +72 -1
- package/dist/tools/editor/node.js +7 -2
- package/dist/tools/editor/particles.js +7 -2
- package/dist/tools/editor/project_input_test.js +16 -2
- package/dist/tools/editor/resource.js +54 -7
- package/dist/tools/editor/scene.js +31 -4
- package/dist/tools/editor/shader.js +19 -3
- package/dist/tools/editor/spatial.js +16 -1
- package/dist/tools/editor/tiles.js +16 -1
- package/dist/tools/editor/ui.js +29 -2
- package/dist/tools/editor.js +20 -13
- package/dist/tools/knowledge.js +14 -1
- package/dist/tools/lsp-common.js +29 -0
- package/dist/tools/lsp.js +87 -8
- package/dist/tools/netcode.js +47 -4
- package/dist/tools/processes.js +14 -2
- package/dist/tools/runtime.js +49 -9
- package/dist/tools/tabletop.js +173 -7
- package/dist/tools/vcs.js +138 -22
- package/dist/toolsets.js +2 -2
- package/package.json +8 -2
package/dist/tools/tabletop.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { gate } from "../confirm.js";
|
|
3
|
-
import {
|
|
4
|
+
import { readFileText, resolveExistingFile, resolveInsideProject, resolveWriteTarget } from "../paths.js";
|
|
4
5
|
/**
|
|
5
6
|
* Group N — Card / board / piece authoring composites (`card_*`, and later
|
|
6
7
|
* `board_*` / `piece_*` / `interact_*`).
|
|
@@ -550,9 +551,13 @@ export async function emitDeckFromTable(emit, readFile, args) {
|
|
|
550
551
|
const base = sceneRootName(args.template_path);
|
|
551
552
|
const face_up = args.face_up ?? true;
|
|
552
553
|
const format = args.format ?? (args.table_path.toLowerCase().endsWith(".json") ? "json" : "csv");
|
|
554
|
+
// The reader seam guarantees the file EXISTS and is a regular file inside the
|
|
555
|
+
// project root (see resolveExistingFile), so "" here means one thing only: a
|
|
556
|
+
// real, reachable, zero-byte table. It used to mean four things at once and
|
|
557
|
+
// reported the wrong one for three of them.
|
|
553
558
|
const text = readFile(args.table_path);
|
|
554
559
|
if (text === "")
|
|
555
|
-
throw new ComposeError("
|
|
560
|
+
throw new ComposeError("empty_table", `Table ${args.table_path} is empty (0 bytes) — there are no rows to stamp.`);
|
|
556
561
|
const allRows = readTableRows(text, format);
|
|
557
562
|
const rows_read = allRows.length;
|
|
558
563
|
const header = new Set();
|
|
@@ -1361,7 +1366,70 @@ export async function emitAddDropZone(emit, args) {
|
|
|
1361
1366
|
// ------------------------------------------------------------- registration ----
|
|
1362
1367
|
export function registerTabletopTools(server, bridge, config) {
|
|
1363
1368
|
const emit = (method, params) => bridge.request(method, params);
|
|
1364
|
-
|
|
1369
|
+
// The reader REFUSES a path that escapes the project root, and separates
|
|
1370
|
+
// "missing" / "a directory" / "the project root" from "an empty file" —
|
|
1371
|
+
// `readFileText` alone answered "" to all four and the caller was told
|
|
1372
|
+
// "does it exist?" for three things that did.
|
|
1373
|
+
const readFile = (p) => readFileText(resolveExistingFile(p, config.projectPath, "table_path"));
|
|
1374
|
+
/**
|
|
1375
|
+
* Guard a caller-supplied res:// scene/script target before anything is
|
|
1376
|
+
* written: refuse a path that escapes the root, refuse an existing target
|
|
1377
|
+
* unless `overwrite`, and — when overwriting — close a stale editor tab.
|
|
1378
|
+
*
|
|
1379
|
+
* That last step is not defensive. The addon's `scene.new` saves a fresh
|
|
1380
|
+
* single-root scene to `path` and then calls
|
|
1381
|
+
* `EditorInterface.open_scene_from_path(path)`. When that scene is ALREADY
|
|
1382
|
+
* OPEN the editor switches to the existing tab, whose in-memory tree is the
|
|
1383
|
+
* OLD one — so every `node.add` that follows lands on top of it and the save
|
|
1384
|
+
* writes both trees. Measured (session 161): a second `board_create` against
|
|
1385
|
+
* the same path took a 5-node scene to 9, reported `saved: true` and a
|
|
1386
|
+
* `node_count` of 5, and never mentioned the four orphans it had just made.
|
|
1387
|
+
*/
|
|
1388
|
+
function guardWriteTarget(p, overwrite, label) {
|
|
1389
|
+
resolveWriteTarget(p, config.projectPath, { overwrite, label });
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* The second half, run only AFTER the confirmation gate has been cleared —
|
|
1393
|
+
* closing a scene tab is a visible side effect and must not happen on a call
|
|
1394
|
+
* the caller then declines.
|
|
1395
|
+
*/
|
|
1396
|
+
async function clearStaleTab(p) {
|
|
1397
|
+
// Unconditional, and deliberately NOT gated on "the file exists": the stale
|
|
1398
|
+
// tab is the editor's, not the disk's. Measured — deleting the .tscn out
|
|
1399
|
+
// from under an OPEN editor tab still produced an append, because
|
|
1400
|
+
// open_scene_from_path reuses the tab whatever the disk says. Keying the
|
|
1401
|
+
// close on `exists` would have left that case open; keying it on "is this
|
|
1402
|
+
// scene open" closes the whole class.
|
|
1403
|
+
const open = await emit("scene.list_open", {});
|
|
1404
|
+
const scenes = Array.isArray(open?.scenes) ? open.scenes.map(String) : [];
|
|
1405
|
+
if (!scenes.includes(p))
|
|
1406
|
+
return; // not open: scene.new reopens it fresh from disk
|
|
1407
|
+
if (open?.current !== p) {
|
|
1408
|
+
// Only the CURRENT scene can be closed, so a non-current target has to be
|
|
1409
|
+
// made current first — and `scene.open` needs the file to still be there.
|
|
1410
|
+
// A scene whose file was deleted from under the editor cannot be reached
|
|
1411
|
+
// this way; skip rather than turn a create into "Scene not found". That
|
|
1412
|
+
// residual case needs someone to delete a .tscn behind the editor's back,
|
|
1413
|
+
// which no tool in this surface does.
|
|
1414
|
+
if (!fs.existsSync(resolveInsideProject(p, config.projectPath, "path")))
|
|
1415
|
+
return;
|
|
1416
|
+
await emit("scene.open", { path: p });
|
|
1417
|
+
}
|
|
1418
|
+
try {
|
|
1419
|
+
await emit("scene.close", { path: p });
|
|
1420
|
+
}
|
|
1421
|
+
catch (err) {
|
|
1422
|
+
// EditorInterface.close_scene is Godot 4.4+; the addon answers
|
|
1423
|
+
// "unsupported" below that. Refuse rather than proceed — proceeding is
|
|
1424
|
+
// exactly the silent append this guard exists to stop.
|
|
1425
|
+
if (err?.code === "unsupported") {
|
|
1426
|
+
throw new ComposeError("overwrite_unsupported", `Cannot overwrite ${p}: it is open in the editor, and closing a scene requires Godot 4.4+. ` +
|
|
1427
|
+
`Close that scene tab by hand and retry, or write to a different path. Overwriting an OPEN ` +
|
|
1428
|
+
`scene without closing it appends to the stale tab instead of replacing it.`);
|
|
1429
|
+
}
|
|
1430
|
+
throw err;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1365
1433
|
const slotSchema = z.object({
|
|
1366
1434
|
name: z.string().describe("Slot key used by card_instance / card_deck_from_table data (e.g. title, cost, body, art)"),
|
|
1367
1435
|
kind: z.enum(["label", "rich_text", "texture", "panel", "badge"]).describe("label→Label, rich_text→RichTextLabel, texture→TextureRect, panel→Panel, badge→Label-in-Panel"),
|
|
@@ -1411,7 +1479,7 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1411
1479
|
}).optional(),
|
|
1412
1480
|
}).optional().describe("Inline theme built via theme_create + theme_set_*"),
|
|
1413
1481
|
script_path: z.string().optional().describe("Generated card script path (default derives from `path`)"),
|
|
1414
|
-
overwrite: z.boolean().optional().describe("
|
|
1482
|
+
overwrite: z.boolean().optional().describe("Replace an existing template at `path` (default false). With overwrite omitted or false an existing `path` is REFUSED (`exists`) rather than written — before 1.39.0 this flag was declared and never read, and a repeat call APPENDED to the existing scene while reporting success. With overwrite:true the scene is replaced, which requires closing it first if it is open in the editor (Godot 4.4+; below that an open target is refused rather than appended to)."),
|
|
1415
1483
|
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
1416
1484
|
},
|
|
1417
1485
|
}, async (raw) => {
|
|
@@ -1420,10 +1488,30 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1420
1488
|
return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
|
|
1421
1489
|
if (a.theme_path && a.theme)
|
|
1422
1490
|
return fail({ code: "bad_params", message: "Pass either theme_path or an inline theme, not both" });
|
|
1491
|
+
try {
|
|
1492
|
+
guardWriteTarget(a.path, a.overwrite, "path");
|
|
1493
|
+
if (a.script_path !== undefined)
|
|
1494
|
+
resolveInsideProject(a.script_path, config.projectPath, "script_path");
|
|
1495
|
+
if (a.theme_path !== undefined)
|
|
1496
|
+
resolveInsideProject(a.theme_path, config.projectPath, "theme_path");
|
|
1497
|
+
// 🔴 NESTED. `back.art` and `theme.font_path` live ONE LEVEL DOWN in the schema,
|
|
1498
|
+
// which is why no enumeration this project ever ran could see them: enum163 walks
|
|
1499
|
+
// TOP-LEVEL `inputSchema.properties` only. Measured — `back.art` was written
|
|
1500
|
+
// VERBATIM into the saved scene, and `theme.font_path` LOADED a font from outside
|
|
1501
|
+
// the root through all three spellings.
|
|
1502
|
+
if (a.back?.art !== undefined)
|
|
1503
|
+
resolveInsideProject(a.back.art, config.projectPath, "back.art");
|
|
1504
|
+
if (a.theme?.font_path !== undefined)
|
|
1505
|
+
resolveInsideProject(a.theme.font_path, config.projectPath, "theme.font_path");
|
|
1506
|
+
}
|
|
1507
|
+
catch (err) {
|
|
1508
|
+
return fail(err);
|
|
1509
|
+
}
|
|
1423
1510
|
const blocked = await gate(server, a.confirm, `Create card template scene + script at ${a.path}`);
|
|
1424
1511
|
if (blocked)
|
|
1425
1512
|
return blocked;
|
|
1426
1513
|
try {
|
|
1514
|
+
await clearStaleTab(a.path);
|
|
1427
1515
|
return ok(await emitCardTemplate(emit, a));
|
|
1428
1516
|
}
|
|
1429
1517
|
catch (err) {
|
|
@@ -1446,6 +1534,12 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1446
1534
|
},
|
|
1447
1535
|
}, async (raw) => {
|
|
1448
1536
|
const a = raw;
|
|
1537
|
+
try {
|
|
1538
|
+
resolveInsideProject(a.template_path, config.projectPath, "template_path");
|
|
1539
|
+
}
|
|
1540
|
+
catch (err) {
|
|
1541
|
+
return fail(err);
|
|
1542
|
+
}
|
|
1449
1543
|
try {
|
|
1450
1544
|
return ok(await emitCardInstance(emit, a));
|
|
1451
1545
|
}
|
|
@@ -1471,6 +1565,12 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1471
1565
|
},
|
|
1472
1566
|
}, async (raw) => {
|
|
1473
1567
|
const a = raw;
|
|
1568
|
+
try {
|
|
1569
|
+
resolveInsideProject(a.template_path, config.projectPath, "template_path");
|
|
1570
|
+
}
|
|
1571
|
+
catch (err) {
|
|
1572
|
+
return fail(err);
|
|
1573
|
+
}
|
|
1474
1574
|
try {
|
|
1475
1575
|
return ok(await emitCardHand(emit, a));
|
|
1476
1576
|
}
|
|
@@ -1508,6 +1608,15 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1508
1608
|
},
|
|
1509
1609
|
}, async (raw) => {
|
|
1510
1610
|
const a = raw;
|
|
1611
|
+
// 🔴 `table_path` has been guarded since #173 (`resolveExistingFile`, via
|
|
1612
|
+
// `readFile`); `template_path` on the SAME CALL was not. Fourth session running
|
|
1613
|
+
// where the interesting defect is the second parameter on a guarded tool.
|
|
1614
|
+
try {
|
|
1615
|
+
resolveInsideProject(a.template_path, config.projectPath, "template_path");
|
|
1616
|
+
}
|
|
1617
|
+
catch (err) {
|
|
1618
|
+
return fail(err);
|
|
1619
|
+
}
|
|
1511
1620
|
try {
|
|
1512
1621
|
return ok(await emitDeckFromTable(emit, readFile, a));
|
|
1513
1622
|
}
|
|
@@ -1579,17 +1688,27 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1579
1688
|
art: z.string().optional().describe("res:// texture background (Sprite2D under Node2D, TextureRect under Control)"),
|
|
1580
1689
|
size: z.object({ w: z.number().optional(), h: z.number().optional() }).optional().describe("Background size in px (ColorRect / TextureRect)"),
|
|
1581
1690
|
}).optional().describe("Optional background drawn behind the cells"),
|
|
1582
|
-
overwrite: z.boolean().optional().describe("
|
|
1691
|
+
overwrite: z.boolean().optional().describe("Replace an existing board at `path` (default false). With overwrite omitted or false an existing `path` is REFUSED (`exists`) rather than written — before 1.39.0 this flag was declared and never read, and a repeat call APPENDED to the existing scene while reporting success. With overwrite:true the scene is replaced, which requires closing it first if it is open in the editor (Godot 4.4+; below that an open target is refused rather than appended to)."),
|
|
1583
1692
|
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
1584
1693
|
},
|
|
1585
1694
|
}, async (raw) => {
|
|
1586
1695
|
const a = raw;
|
|
1587
1696
|
if (!a.path.startsWith("res://") || !a.path.endsWith(".tscn"))
|
|
1588
1697
|
return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
|
|
1698
|
+
try {
|
|
1699
|
+
guardWriteTarget(a.path, a.overwrite, "path");
|
|
1700
|
+
// 🔴 NESTED — `background.art` is stored verbatim in the saved board scene.
|
|
1701
|
+
if (a.background?.art !== undefined)
|
|
1702
|
+
resolveInsideProject(a.background.art, config.projectPath, "background.art");
|
|
1703
|
+
}
|
|
1704
|
+
catch (err) {
|
|
1705
|
+
return fail(err);
|
|
1706
|
+
}
|
|
1589
1707
|
const blocked = await gate(server, a.confirm, `Create board scene at ${a.path}`);
|
|
1590
1708
|
if (blocked)
|
|
1591
1709
|
return blocked;
|
|
1592
1710
|
try {
|
|
1711
|
+
await clearStaleTab(a.path);
|
|
1593
1712
|
return ok(await emitBoardCreate(emit, a));
|
|
1594
1713
|
}
|
|
1595
1714
|
catch (err) {
|
|
@@ -1633,7 +1752,7 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1633
1752
|
atlas_coords: z.array(z.number().int()).length(2).optional().describe("Tile atlas coordinates [x, y] within the source (default [0, 0])"),
|
|
1634
1753
|
}).optional().describe("Fill the whole grid with one tile (needs an existing `tileset` that has the source); omitted → cells stay empty (a coordinate frame only)"),
|
|
1635
1754
|
layer_name: z.string().optional().describe("TileMapLayer node name (default \"Cells\")"),
|
|
1636
|
-
overwrite: z.boolean().optional().describe("
|
|
1755
|
+
overwrite: z.boolean().optional().describe("Replace an existing board at `path` (default false). With overwrite omitted or false an existing `path` is REFUSED (`exists`) rather than written — before 1.39.0 this flag was declared and never read, and a repeat call APPENDED to the existing scene while reporting success. With overwrite:true the scene is replaced, which requires closing it first if it is open in the editor (Godot 4.4+; below that an open target is refused rather than appended to)."),
|
|
1637
1756
|
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
1638
1757
|
},
|
|
1639
1758
|
}, async (raw) => {
|
|
@@ -1642,10 +1761,21 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1642
1761
|
return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
|
|
1643
1762
|
if (a.tileset !== undefined && !(a.tileset.startsWith("res://") && a.tileset.endsWith(".tres")))
|
|
1644
1763
|
return fail({ code: "bad_params", message: "'tileset' must be a res:// .tres path" });
|
|
1764
|
+
try {
|
|
1765
|
+
guardWriteTarget(a.path, a.overwrite, "path");
|
|
1766
|
+
// A supplied tileset is READ, not written — but `res://../x.tres` would
|
|
1767
|
+
// reach outside the root just the same, so it is resolved too.
|
|
1768
|
+
if (a.tileset !== undefined)
|
|
1769
|
+
resolveInsideProject(a.tileset, config.projectPath, "tileset");
|
|
1770
|
+
}
|
|
1771
|
+
catch (err) {
|
|
1772
|
+
return fail(err);
|
|
1773
|
+
}
|
|
1645
1774
|
const blocked = await gate(server, a.confirm, `Create tile-backed board scene at ${a.path}`);
|
|
1646
1775
|
if (blocked)
|
|
1647
1776
|
return blocked;
|
|
1648
1777
|
try {
|
|
1778
|
+
await clearStaleTab(a.path);
|
|
1649
1779
|
return ok(await emitBoardTileCreate(emit, a));
|
|
1650
1780
|
}
|
|
1651
1781
|
catch (err) {
|
|
@@ -1696,17 +1826,35 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1696
1826
|
color: z.string().regex(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/).optional().describe("Solid ColorRect back colour"),
|
|
1697
1827
|
}).optional().describe("Optional back state; its presence makes the piece two-sided"),
|
|
1698
1828
|
script_path: z.string().optional().describe("Generated piece script path (default derives from `path`)"),
|
|
1699
|
-
overwrite: z.boolean().optional().describe("
|
|
1829
|
+
overwrite: z.boolean().optional().describe("Replace an existing template at `path` (default false). With overwrite omitted or false an existing `path` is REFUSED (`exists`) rather than written — before 1.39.0 this flag was declared and never read, and a repeat call APPENDED to the existing scene while reporting success. With overwrite:true the scene is replaced, which requires closing it first if it is open in the editor (Godot 4.4+; below that an open target is refused rather than appended to)."),
|
|
1700
1830
|
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
1701
1831
|
},
|
|
1702
1832
|
}, async (raw) => {
|
|
1703
1833
|
const a = raw;
|
|
1704
1834
|
if (!a.path.startsWith("res://") || !a.path.endsWith(".tscn"))
|
|
1705
1835
|
return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
|
|
1836
|
+
try {
|
|
1837
|
+
guardWriteTarget(a.path, a.overwrite, "path");
|
|
1838
|
+
if (a.script_path !== undefined)
|
|
1839
|
+
resolveInsideProject(a.script_path, config.projectPath, "script_path");
|
|
1840
|
+
// 🔴 `art` is STORED, not loaded: the escaping path was written verbatim into
|
|
1841
|
+
// the generated .tscn, so the template carried a reference out of the project.
|
|
1842
|
+
if (a.art !== undefined)
|
|
1843
|
+
resolveInsideProject(a.art, config.projectPath, "art");
|
|
1844
|
+
// 🔴 AND ITS NESTED TWIN. #173 guarded `art` and left `back.art` open, because the
|
|
1845
|
+
// enumerator that scoped that work could not see one level down. Same defect,
|
|
1846
|
+
// same tool, three sessions apart — measured storing the escaping path verbatim.
|
|
1847
|
+
if (a.back?.art !== undefined)
|
|
1848
|
+
resolveInsideProject(a.back.art, config.projectPath, "back.art");
|
|
1849
|
+
}
|
|
1850
|
+
catch (err) {
|
|
1851
|
+
return fail(err);
|
|
1852
|
+
}
|
|
1706
1853
|
const blocked = await gate(server, a.confirm, `Create piece template scene + script at ${a.path}`);
|
|
1707
1854
|
if (blocked)
|
|
1708
1855
|
return blocked;
|
|
1709
1856
|
try {
|
|
1857
|
+
await clearStaleTab(a.path);
|
|
1710
1858
|
return ok(await emitPieceTemplate(emit, a));
|
|
1711
1859
|
}
|
|
1712
1860
|
catch (err) {
|
|
@@ -1734,6 +1882,12 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1734
1882
|
},
|
|
1735
1883
|
}, async (raw) => {
|
|
1736
1884
|
const a = raw;
|
|
1885
|
+
try {
|
|
1886
|
+
resolveInsideProject(a.template_path, config.projectPath, "template_path");
|
|
1887
|
+
}
|
|
1888
|
+
catch (err) {
|
|
1889
|
+
return fail(err);
|
|
1890
|
+
}
|
|
1737
1891
|
try {
|
|
1738
1892
|
return ok(await emitPieceInstance(emit, a));
|
|
1739
1893
|
}
|
|
@@ -1790,6 +1944,12 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1790
1944
|
const a = raw;
|
|
1791
1945
|
if (!a.script_path.startsWith("res://") || !a.script_path.endsWith(".gd"))
|
|
1792
1946
|
return fail({ code: "bad_params", message: "'script_path' must be a res:// .gd path" });
|
|
1947
|
+
try {
|
|
1948
|
+
resolveInsideProject(a.script_path, config.projectPath, "script_path");
|
|
1949
|
+
}
|
|
1950
|
+
catch (err) {
|
|
1951
|
+
return fail(err);
|
|
1952
|
+
}
|
|
1793
1953
|
const blocked = await gate(server, a.confirm, `Make ${a.node} draggable (writes script ${a.script_path})`);
|
|
1794
1954
|
if (blocked)
|
|
1795
1955
|
return blocked;
|
|
@@ -1827,6 +1987,12 @@ export function registerTabletopTools(server, bridge, config) {
|
|
|
1827
1987
|
const a = raw;
|
|
1828
1988
|
if (!a.script_path.startsWith("res://") || !a.script_path.endsWith(".gd"))
|
|
1829
1989
|
return fail({ code: "bad_params", message: "'script_path' must be a res:// .gd path" });
|
|
1990
|
+
try {
|
|
1991
|
+
resolveInsideProject(a.script_path, config.projectPath, "script_path");
|
|
1992
|
+
}
|
|
1993
|
+
catch (err) {
|
|
1994
|
+
return fail(err);
|
|
1995
|
+
}
|
|
1830
1996
|
const blocked = await gate(server, a.confirm, `Add a drop zone on ${a.node} (writes script ${a.script_path})`);
|
|
1831
1997
|
if (blocked)
|
|
1832
1998
|
return blocked;
|
package/dist/tools/vcs.js
CHANGED
|
@@ -57,6 +57,30 @@ function gitFail(r) {
|
|
|
57
57
|
content: [{ type: "text", text: `git error${r.code != null ? ` [${r.code}]` : ""}: ${msg}${hint}` }],
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The paths from a `git ... --name-only` run, in git's own order and dropping the
|
|
62
|
+
* trailing blank. Used by `vcs_restore` to MEASURE what changed rather than echo what
|
|
63
|
+
* was requested — see the comment at its handler for why that distinction is the whole
|
|
64
|
+
* point (D5, 155 §2; the same family as #181, #183 and #188).
|
|
65
|
+
*/
|
|
66
|
+
function nameOnly(stdout) {
|
|
67
|
+
return stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Classify a `vcs_restore` run from the two readings around it.
|
|
71
|
+
*
|
|
72
|
+
* 🔴 A PURE FUNCTION TAKING ITS POPULATIONS AS PARAMETERS, AND THAT IS THE POINT
|
|
73
|
+
* (173 §6, whose own reverse sweep caught the same shape in the commit written to fix
|
|
74
|
+
* that session). Inlined in the handler, `stranded` could only ever be built from what
|
|
75
|
+
* a real `git restore` produces — which, when it works, is always the empty list. A
|
|
76
|
+
* collector you only ever assert is EMPTY is a collector nobody has proved collects,
|
|
77
|
+
* so the collapse case has to be constructible without git's cooperation.
|
|
78
|
+
*/
|
|
79
|
+
export function restoreOutcome(rels, wasDirty, stillDirty) {
|
|
80
|
+
const stuck = new Set(stillDirty);
|
|
81
|
+
const restored = wasDirty.filter((p) => !stuck.has(p));
|
|
82
|
+
return { restored, count: restored.length, requested: rels, stranded: [...stuck] };
|
|
83
|
+
}
|
|
60
84
|
/**
|
|
61
85
|
* Truncate long text so a single tool result stays reasonable; report whether it
|
|
62
86
|
* was cut. Keeps the HEAD (not the tail): for a patch the first changed files and
|
|
@@ -219,44 +243,68 @@ export function registerVcsTools(server, cfg) {
|
|
|
219
243
|
server.registerTool("vcs_branch_list", {
|
|
220
244
|
title: "Git branches",
|
|
221
245
|
description: "List branches with their short object name and a flag for the current branch. Local only by default; " +
|
|
222
|
-
"set remotes=true to include remote-tracking branches.
|
|
246
|
+
"set remotes=true to include remote-tracking branches (flagged remote=true). On a detached HEAD, " +
|
|
247
|
+
"`current` is null and `detached` is true — matching vcs_status — and no pseudo-branch is listed. " +
|
|
248
|
+
"Read-only.",
|
|
223
249
|
inputSchema: {
|
|
224
250
|
remotes: z.boolean().optional().describe("Include remote-tracking branches (default false)"),
|
|
225
251
|
},
|
|
226
252
|
}, async ({ remotes }) => {
|
|
227
|
-
const args = [
|
|
253
|
+
const args = [
|
|
254
|
+
"branch", "--no-color",
|
|
255
|
+
`--format=%(refname)${UNIT}%(refname:short)${UNIT}%(objectname:short)${UNIT}%(HEAD)`,
|
|
256
|
+
];
|
|
228
257
|
if (remotes)
|
|
229
258
|
args.push("--all");
|
|
230
259
|
const r = await git(cfg, args);
|
|
231
260
|
if (!r.ok)
|
|
232
261
|
return gitFail(r);
|
|
233
262
|
let current = null;
|
|
263
|
+
let detached = false;
|
|
234
264
|
const branches = r.stdout
|
|
235
265
|
.split("\n")
|
|
236
266
|
.filter(Boolean)
|
|
237
267
|
.map((line) => {
|
|
238
|
-
const [name, short_sha, head] = line.split(UNIT);
|
|
268
|
+
const [full, name, short_sha, head] = line.split(UNIT);
|
|
239
269
|
const isCurrent = head.trim() === "*";
|
|
270
|
+
// On a detached HEAD `git branch` emits a pseudo-entry whose refname is the
|
|
271
|
+
// literal "(HEAD detached at <sha>)" rather than refs/heads/… . It is NOT a
|
|
272
|
+
// branch — listing it as `current` contradicted vcs_status, which reports
|
|
273
|
+
// null for the same repo, and no vcs_switch could ever reach it.
|
|
274
|
+
if (!full.startsWith("refs/")) {
|
|
275
|
+
if (isCurrent)
|
|
276
|
+
detached = true;
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
240
279
|
if (isCurrent)
|
|
241
280
|
current = name;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
281
|
+
// Discriminate off the FULL refname: `%(refname:short)` of a remote-tracking
|
|
282
|
+
// branch is "origin/main", never "remotes/origin/main", so the old
|
|
283
|
+
// name.startsWith("remotes/") test could never match and every branch came
|
|
284
|
+
// back remote=false — including under remotes=true, the flag's whole point.
|
|
285
|
+
return { name, short_sha, current: isCurrent, remote: full.startsWith("refs/remotes/") };
|
|
286
|
+
})
|
|
287
|
+
.filter((b) => b !== null);
|
|
288
|
+
return ok({ current, branches, count: branches.length, detached });
|
|
245
289
|
});
|
|
246
290
|
// ---- vcs_blame -----------------------------------------------------------
|
|
247
291
|
server.registerTool("vcs_blame", {
|
|
248
292
|
title: "Git blame",
|
|
249
293
|
description: "Per-line last-change attribution for a file: for each line, the short commit, author, ISO date, and the " +
|
|
250
|
-
"line text. Optionally restrict to a [start,end] line range (1-based, inclusive)
|
|
294
|
+
"line text. Optionally restrict to a [start,end] line range (1-based, inclusive); either bound may be " +
|
|
295
|
+
"given alone (start alone runs to end-of-file). Read-only.",
|
|
251
296
|
inputSchema: {
|
|
252
297
|
path: z.string().describe("File to blame (res:// or repo-relative)"),
|
|
253
|
-
start: z.number().int().positive().optional().describe("First line (1-based, inclusive)"),
|
|
254
|
-
end: z.number().int().positive().optional().describe("Last line (1-based, inclusive)"),
|
|
298
|
+
start: z.number().int().positive().optional().describe("First line (1-based, inclusive). May be given without `end`."),
|
|
299
|
+
end: z.number().int().positive().optional().describe("Last line (1-based, inclusive). May be given without `start`."),
|
|
255
300
|
},
|
|
256
301
|
}, async ({ path, start, end }) => {
|
|
257
302
|
const args = ["blame", "--line-porcelain"];
|
|
303
|
+
// An omitted end must be an EMPTY field: `-L 3,` blames line 3 to end-of-file.
|
|
304
|
+
// `$` is a `git log -L` form and `git blame` REJECTS it with usage exit 129, so
|
|
305
|
+
// the old `end ?? "$"` made every start-without-end call fail. Measured, not read.
|
|
258
306
|
if (start != null || end != null)
|
|
259
|
-
args.push("-L", `${start ?? 1},${end ?? "
|
|
307
|
+
args.push("-L", `${start ?? 1},${end ?? ""}`);
|
|
260
308
|
args.push("--", toRepoPath(path));
|
|
261
309
|
const r = await git(cfg, args);
|
|
262
310
|
if (!r.ok)
|
|
@@ -322,7 +370,10 @@ export function registerVcsTools(server, cfg) {
|
|
|
322
370
|
title: "Git restore (discard changes)",
|
|
323
371
|
description: "Discard uncommitted working-tree changes to the given paths, restoring them from the index/HEAD " +
|
|
324
372
|
"(`git restore -- <paths>`). DESTRUCTIVE — the discarded edits are unrecoverable — so it is " +
|
|
325
|
-
"elicitation-gated: pass confirm:true to bypass the prompt on clients that can't elicit."
|
|
373
|
+
"elicitation-gated: pass confirm:true to bypass the prompt on clients that can't elicit. " +
|
|
374
|
+
"`restored` is MEASURED, not echoed: it lists only the paths git actually changed, so a path with " +
|
|
375
|
+
"nothing to discard is absent from it rather than reported as discarded work. `requested` carries " +
|
|
376
|
+
"what you asked for, and `stranded` names any path still dirty afterwards (a partial, not an error).",
|
|
326
377
|
inputSchema: {
|
|
327
378
|
paths: z.array(z.string()).min(1).describe("Paths to discard changes for (res:// or repo-relative)"),
|
|
328
379
|
confirm: z.boolean().optional().describe("Skip the confirmation prompt"),
|
|
@@ -332,17 +383,44 @@ export function registerVcsTools(server, cfg) {
|
|
|
332
383
|
const blocked = await gate(server, confirm, `Discard working-tree changes to: ${rels.join(", ")}`);
|
|
333
384
|
if (blocked)
|
|
334
385
|
return blocked;
|
|
386
|
+
// 🔴 D5 (155 §2), AND THE THIRD MEMBER OF #181/#183/#188's FAMILY: this used to
|
|
387
|
+
// return `restored: rels` — THE REQUEST, ECHOED. `git restore` exits 0 for a path
|
|
388
|
+
// with nothing to discard, so asking to discard five files of which one was dirty
|
|
389
|
+
// reported all five as restored, and the caller of a DESTRUCTIVE, gated tool was
|
|
390
|
+
// told it had thrown away work it had not touched. Every existing test restored a
|
|
391
|
+
// path that was dirty, so the clean-path branch had never run anywhere.
|
|
392
|
+
//
|
|
393
|
+
// What `git restore -- <paths>` discards is exactly the working-tree-vs-index
|
|
394
|
+
// diff, so that diff, read BEFORE and AFTER, is the measurement — not the request.
|
|
395
|
+
const before = await git(cfg, ["diff", "--name-only", "--", ...rels]);
|
|
396
|
+
if (!before.ok)
|
|
397
|
+
return gitFail(before);
|
|
398
|
+
const wasDirty = nameOnly(before.stdout);
|
|
335
399
|
const r = await git(cfg, ["restore", "--", ...rels]);
|
|
336
400
|
if (!r.ok)
|
|
337
401
|
return gitFail(r);
|
|
338
|
-
|
|
402
|
+
const after = await git(cfg, ["diff", "--name-only", "--", ...rels]);
|
|
403
|
+
if (!after.ok)
|
|
404
|
+
return gitFail(after);
|
|
405
|
+
// Three outcomes, and one list cannot carry them (#188's two booleans, as lists):
|
|
406
|
+
// a path that was dirty and is now clean was RESTORED; a path git still reports as
|
|
407
|
+
// dirty is STRANDED; and `requested` stays available so a caller can see that the
|
|
408
|
+
// other paths had nothing to discard rather than having to infer it.
|
|
409
|
+
//
|
|
410
|
+
// A stranded path is a PARTIAL, not an error — deliberately, and for #188 §7's
|
|
411
|
+
// reason inverted: work WAS discarded for the other paths, and `_err` would claim
|
|
412
|
+
// nothing happened, which is the same misdescription pointing the other way.
|
|
413
|
+
return ok(restoreOutcome(rels, wasDirty, nameOnly(after.stdout)));
|
|
339
414
|
});
|
|
340
415
|
// ---- vcs_stash — push/pop/list ungated; drop GATED (destroys an entry) ----
|
|
341
416
|
server.registerTool("vcs_stash", {
|
|
342
417
|
title: "Git stash",
|
|
343
418
|
description: "Manage stashes: op='push' saves and reverts your working changes (optional message); 'pop' " +
|
|
344
419
|
"re-applies the latest stash; 'list' returns the stash entries; 'drop' deletes a stash entry. " +
|
|
345
|
-
"Only 'drop' is destructive and elicitation-gated (confirm:true bypasses); push/pop/list are not."
|
|
420
|
+
"Only 'drop' is destructive and elicitation-gated (confirm:true bypasses); push/pop/list are not. " +
|
|
421
|
+
"op='push' ERRORS when nothing was stashed (no tracked file has uncommitted changes) rather than " +
|
|
422
|
+
"reporting success — a success there would tell you work is parked when it is not. Untracked files " +
|
|
423
|
+
"are never stashed.",
|
|
346
424
|
inputSchema: {
|
|
347
425
|
op: z.enum(["push", "pop", "list", "drop"]).describe("Stash operation"),
|
|
348
426
|
message: z.string().optional().describe("Message for op='push'"),
|
|
@@ -370,20 +448,45 @@ export function registerVcsTools(server, cfg) {
|
|
|
370
448
|
return gitFail(r);
|
|
371
449
|
return ok({ op, message: r.stdout.trim() || "dropped", stashes: [] });
|
|
372
450
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
:
|
|
377
|
-
|
|
451
|
+
if (op === "push") {
|
|
452
|
+
// `git stash push` EXITS 0 printing "No local changes to save" when there is
|
|
453
|
+
// nothing to stash. Passing that through as success is the worst shape this
|
|
454
|
+
// family has: the caller believes their work is safely parked and may then
|
|
455
|
+
// switch or restore over it. Decide on refs/stash BEFORE vs AFTER rather than
|
|
456
|
+
// on git's wording, which is not a stable interface across versions.
|
|
457
|
+
// Note untracked-only trees stash NOTHING here (no -u) — measured.
|
|
458
|
+
const before = await git(cfg, ["rev-parse", "--quiet", "--verify", "refs/stash"]);
|
|
459
|
+
const r = await git(cfg, ["stash", "push", ...(message ? ["-m", message] : [])]);
|
|
460
|
+
if (!r.ok)
|
|
461
|
+
return gitFail(r);
|
|
462
|
+
const after = await git(cfg, ["rev-parse", "--quiet", "--verify", "refs/stash"]);
|
|
463
|
+
const beforeOid = before.ok ? before.stdout.trim() : "";
|
|
464
|
+
const afterOid = after.ok ? after.stdout.trim() : "";
|
|
465
|
+
if (afterOid === "" || afterOid === beforeOid) {
|
|
466
|
+
return {
|
|
467
|
+
isError: true,
|
|
468
|
+
content: [{
|
|
469
|
+
type: "text",
|
|
470
|
+
text: "Nothing was stashed — no tracked file has uncommitted changes, so your working tree " +
|
|
471
|
+
"is unchanged and no stash entry was created. Do not treat this as work being parked. " +
|
|
472
|
+
`(git said: ${r.stdout.trim() || "nothing"})`,
|
|
473
|
+
}],
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
return ok({ op, message: r.stdout.trim() || "push ok", stashes: [] });
|
|
477
|
+
}
|
|
478
|
+
const r = await git(cfg, ["stash", "pop", ...(ref ? [ref] : [])]);
|
|
378
479
|
if (!r.ok)
|
|
379
480
|
return gitFail(r);
|
|
380
|
-
return ok({ op, message: r.stdout.trim() ||
|
|
481
|
+
return ok({ op, message: r.stdout.trim() || "pop ok", stashes: [] });
|
|
381
482
|
});
|
|
382
483
|
// ---- vcs_branch_create — ungated (reversible) ----------------------------
|
|
383
484
|
server.registerTool("vcs_branch_create", {
|
|
384
485
|
title: "Git branch (create)",
|
|
385
486
|
description: "Create a new branch, optionally starting from a given ref (default HEAD), and optionally switch to " +
|
|
386
|
-
"it. Reversible (`git branch -d`), so not gated. Errors clearly if the branch already exists."
|
|
487
|
+
"it. Reversible (`git branch -d`), so not gated. Errors clearly if the branch already exists. If " +
|
|
488
|
+
"switch=true and the switch is refused (e.g. local changes would be overwritten), the error SAYS the " +
|
|
489
|
+
"branch was still created — create and switch are two git calls and only the second can fail.",
|
|
387
490
|
inputSchema: {
|
|
388
491
|
name: z.string().min(1).describe("New branch name"),
|
|
389
492
|
from: z.string().optional().describe("Start point (branch, tag, or sha; default HEAD)"),
|
|
@@ -396,8 +499,21 @@ export function registerVcsTools(server, cfg) {
|
|
|
396
499
|
let switched = false;
|
|
397
500
|
if (doSwitch) {
|
|
398
501
|
const sw = await git(cfg, ["switch", name]);
|
|
399
|
-
if (!sw.ok)
|
|
400
|
-
|
|
502
|
+
if (!sw.ok) {
|
|
503
|
+
// The branch EXISTS at this point. Returning only the switch failure strands
|
|
504
|
+
// the caller: they believe nothing happened, retry, and get "already exists"
|
|
505
|
+
// from a branch they created themselves. Report BOTH halves.
|
|
506
|
+
const failure = gitFail(sw);
|
|
507
|
+
return {
|
|
508
|
+
isError: true,
|
|
509
|
+
content: [{
|
|
510
|
+
type: "text",
|
|
511
|
+
text: `Branch '${name}' WAS created, but switching to it failed — you are still on the ` +
|
|
512
|
+
`previous branch. Delete it with \`git branch -d ${name}\` if it is unwanted, or ` +
|
|
513
|
+
`resolve the block below and switch with vcs_switch. ${failure.content[0].text}`,
|
|
514
|
+
}],
|
|
515
|
+
};
|
|
516
|
+
}
|
|
401
517
|
switched = true;
|
|
402
518
|
}
|
|
403
519
|
return ok({ created: true, name, from: from ?? null, switched });
|
package/dist/toolsets.js
CHANGED
|
@@ -31,12 +31,12 @@ export function buildToolsets(d) {
|
|
|
31
31
|
d.onPeers?.(peers);
|
|
32
32
|
return [
|
|
33
33
|
{ id: "cli", describe: "Plane B — headless CLI (no editor required)", run: () => registerCliTools(s, c) },
|
|
34
|
-
{ id: "editor", describe: "Plane A — live editor authoring (addon bridge)", run: () => registerEditorTools(s, stub(d.bridge)) },
|
|
34
|
+
{ id: "editor", describe: "Plane A — live editor authoring (addon bridge)", run: () => registerEditorTools(s, stub(d.bridge), c) },
|
|
35
35
|
{ id: "lsp", describe: "Plane D — GDScript language server (LSP)", run: () => registerLspTools(s, stub(d.lsp), c) },
|
|
36
36
|
{ id: "cslsp", describe: "Plane D — C# language server (OmniSharp)", run: () => registerCsLspTools(s, stub(d.csLsp), c) },
|
|
37
37
|
{ id: "dap", describe: "Plane D — GDScript debug adapter (DAP)", run: () => registerDapTools(s, stub(d.dap), c) },
|
|
38
38
|
{ id: "csdap", describe: "Plane D — C# debug adapter (netcoredbg)", run: () => registerCsDapTools(s, stub(d.csDap), c) },
|
|
39
|
-
{ id: "runtime", describe: "Plane C — running-game runtime + verification family (incl. F6 multi-peer)", run: () => registerRuntimeTools(s, stub(d.runtime), peers) },
|
|
39
|
+
{ id: "runtime", describe: "Plane C — running-game runtime + verification family (incl. F6 multi-peer)", run: () => registerRuntimeTools(s, stub(d.runtime), peers, c) },
|
|
40
40
|
{ id: "processes", describe: "Managed run + captured console output", run: () => { const h = registerProcessTools(s, c); d.onProcesses?.(h); } },
|
|
41
41
|
{ id: "knowledge", describe: "Group K — host-side project grep / symbol & idiom lookup", run: () => registerKnowledgeTools(s, c) },
|
|
42
42
|
{ id: "vcs", describe: "Group L — read-only version control (git) over the project", run: () => registerVcsTools(s, c) },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakpoint-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.72.7",
|
|
4
4
|
"description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol — developed and tested with Claude — across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,13 @@
|
|
|
43
43
|
"start": "node dist/index.js",
|
|
44
44
|
"dev": "tsc && node dist/index.js",
|
|
45
45
|
"stage-addon": "node scripts/stage-addon.mjs",
|
|
46
|
-
"prepublishOnly": "npm run build && npm run stage-addon"
|
|
46
|
+
"prepublishOnly": "npm run build && npm run stage-addon",
|
|
47
|
+
"path-cohort": "node scripts/path-cohort.mjs",
|
|
48
|
+
"tautology-gate": "node scripts/tautology_gate.selftest.mjs && node scripts/tautology_gate.mjs",
|
|
49
|
+
"scope-gate": "python3 ../scripts/scope_gate.py",
|
|
50
|
+
"instrument-gate": "python3 ../scripts/instrument_gate.py",
|
|
51
|
+
"ledger-selftest": "node test-integration/_path_ledger.selftest.mjs",
|
|
52
|
+
"workspace-selftest": "node test-integration/_workspace.selftest.mjs"
|
|
47
53
|
},
|
|
48
54
|
"engines": {
|
|
49
55
|
"node": ">=18"
|