@rubytech/create-maxy-code 0.1.125 → 0.1.126
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/index.js +11 -1
- package/package.json +2 -2
- package/payload/platform/lib/graph-style/dist/index.d.ts +78 -0
- package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -0
- package/payload/platform/lib/graph-style/dist/index.js +296 -0
- package/payload/platform/lib/graph-style/dist/index.js.map +1 -0
- package/payload/platform/lib/graph-style/src/__tests__/parity.test.ts +114 -0
- package/payload/platform/lib/graph-style/src/index.ts +307 -0
- package/payload/platform/lib/graph-style/tsconfig.json +9 -0
- package/payload/platform/lib/graph-style/vitest.config.ts +9 -0
- package/payload/platform/package-lock.json +318 -0
- package/payload/platform/package.json +3 -2
- package/payload/platform/plugins/.claude-plugin/marketplace.json +5 -0
- package/payload/platform/plugins/docs/references/visitor-graph.md +4 -3
- package/payload/platform/plugins/graph-viewer/.claude-plugin/plugin.json +8 -0
- package/payload/platform/plugins/graph-viewer/PLUGIN.md +56 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/index.d.ts +7 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/index.d.ts.map +1 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/index.js +74 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/index.js.map +1 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/lib/neo4j.js +43 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/render/draw.d.ts +28 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/render/draw.d.ts.map +1 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/render/draw.js +73 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/render/draw.js.map +1 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/render/layout.d.ts +40 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/render/layout.d.ts.map +1 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/render/layout.js +77 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/render/layout.js.map +1 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.d.ts +45 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.d.ts.map +1 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js +170 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js.map +1 -0
- package/payload/platform/plugins/graph-viewer/mcp/package.json +25 -0
- package/payload/platform/plugins/graph-viewer/mcp/vitest.config.ts +8 -0
- package/payload/platform/plugins/graph-viewer/skills/render-graph/SKILL.md +35 -0
- package/payload/platform/scripts/check-plugin-tools-mcp-consistency.mjs +136 -0
- package/payload/server/public/assets/{admin-D6IfAzYY.js → admin-FcRHAL-3.js} +7 -7
- package/payload/server/public/assets/{data-BGbQyufe.js → data-Ds37mflX.js} +1 -1
- package/payload/server/public/assets/{graph-D-1lRTeL.js → graph-CmWRhaiS.js} +1 -1
- package/payload/server/public/assets/graph-labels-Ch2r00Gt.js +1 -0
- package/payload/server/public/assets/page-BOtNny_4.js +51 -0
- package/payload/server/public/assets/page-BcHhJXUt.js +1 -0
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +236 -92
- package/payload/server/public/assets/graph-labels-BRXJHNYE.js +0 -1
- package/payload/server/public/assets/page-B4oirCvn.js +0 -1
- package/payload/server/public/assets/page-FmJ7PIYx.js +0 -51
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graph-render — produce an inline PNG of an account-scoped Neo4j
|
|
3
|
+
* subgraph rooted at a given nodeId, walking up to `hopDepth` hops.
|
|
4
|
+
*
|
|
5
|
+
* Surface contract:
|
|
6
|
+
* - The root node must exist and belong to the caller's account.
|
|
7
|
+
* A missing or foreign-account root returns a structured refusal,
|
|
8
|
+
* never throws.
|
|
9
|
+
* - The expansion is bounded by both `hopDepth` (max 3 to keep the
|
|
10
|
+
* image legible) and a hard 200-node cap. Subgraphs that exceed
|
|
11
|
+
* the cap render the first 200 nodes and carry a `truncated`
|
|
12
|
+
* envelope so the agent can tell the operator.
|
|
13
|
+
* - `:Trashed` nodes are excluded everywhere — matches memory-search.
|
|
14
|
+
* - One observability line per invocation, prefixed `[graph-render]`.
|
|
15
|
+
*
|
|
16
|
+
* The Cypher hopDepth is INLINED into the query string after a strict
|
|
17
|
+
* validate (`Number.isInteger && 0 <= n <= 3`) because Neo4j does not
|
|
18
|
+
* parameterise variable-length path bounds. Anything outside that range
|
|
19
|
+
* fails fast at the param-validation layer, well before string assembly.
|
|
20
|
+
*/
|
|
21
|
+
import { getSession } from "../lib/neo4j.js";
|
|
22
|
+
import { layoutGraph } from "../render/layout.js";
|
|
23
|
+
import { drawGraph } from "../render/draw.js";
|
|
24
|
+
const NODE_CAP = 200;
|
|
25
|
+
const MAX_HOP_DEPTH = 3;
|
|
26
|
+
const IMAGE_WIDTH = 1200;
|
|
27
|
+
const IMAGE_HEIGHT = 900;
|
|
28
|
+
function logLine(line) {
|
|
29
|
+
process.stderr.write(`${line}\n`);
|
|
30
|
+
}
|
|
31
|
+
export async function graphRender(params) {
|
|
32
|
+
const { accountId, nodeId } = params;
|
|
33
|
+
const hopDepth = params.hopDepth;
|
|
34
|
+
if (!Number.isInteger(hopDepth) || hopDepth < 0 || hopDepth > MAX_HOP_DEPTH) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: "invalid-input",
|
|
38
|
+
message: `hopDepth must be an integer between 0 and ${MAX_HOP_DEPTH} (got ${String(hopDepth)})`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const t0 = Date.now();
|
|
42
|
+
const session = getSession();
|
|
43
|
+
try {
|
|
44
|
+
// Step 1 — confirm root exists and belongs to this account. The
|
|
45
|
+
// explicit two-step check (rather than relying on the expand to
|
|
46
|
+
// return zero rows) is what lets us return reason="scope" vs
|
|
47
|
+
// reason="empty" with a meaningful message.
|
|
48
|
+
const rootCheck = await session.run(`MATCH (root)
|
|
49
|
+
WHERE elementId(root) = $nodeId
|
|
50
|
+
RETURN root.accountId AS rootAccountId, labels(root) AS rootLabels`, { nodeId });
|
|
51
|
+
if (rootCheck.records.length === 0) {
|
|
52
|
+
logLine(`[graph-render] reject account=${accountId} reason=scope nodeId=${nodeId} cause=not-found`);
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
reason: "scope",
|
|
56
|
+
message: `No node with id ${nodeId} is visible to this account.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const rootAccountId = rootCheck.records[0].get("rootAccountId");
|
|
60
|
+
if (rootAccountId !== accountId) {
|
|
61
|
+
logLine(`[graph-render] reject account=${accountId} reason=scope nodeId=${nodeId} cause=foreign-account`);
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
reason: "scope",
|
|
65
|
+
message: `Node ${nodeId} is not visible to this account.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
logLine(`[graph-render] start account=${accountId} nodeId=${nodeId} hopDepth=${hopDepth}`);
|
|
69
|
+
// Step 2 — expand. Variable-length path bound is inlined after the
|
|
70
|
+
// hopDepth validation above. Account filter + trash filter applied to
|
|
71
|
+
// every node on every path. RELATIONSHIPS only between matched nodes.
|
|
72
|
+
const expandQuery = `
|
|
73
|
+
MATCH (root) WHERE elementId(root) = $nodeId
|
|
74
|
+
MATCH path = (root)-[*0..${hopDepth}]-(n)
|
|
75
|
+
WHERE n.accountId = $accountId AND NOT n:Trashed
|
|
76
|
+
WITH collect(DISTINCT n) AS nodes
|
|
77
|
+
WITH nodes[0..$cap] AS capped, size(nodes) AS totalCount
|
|
78
|
+
UNWIND capped AS node
|
|
79
|
+
WITH collect({ id: elementId(node), labels: labels(node), properties: properties(node) }) AS nodeRows,
|
|
80
|
+
[n IN capped | elementId(n)] AS capIds, totalCount
|
|
81
|
+
OPTIONAL MATCH (a)-[r]->(b)
|
|
82
|
+
WHERE elementId(a) IN capIds AND elementId(b) IN capIds
|
|
83
|
+
RETURN nodeRows,
|
|
84
|
+
collect(DISTINCT { from: elementId(startNode(r)), to: elementId(endNode(r)), type: type(r) }) AS edgeRows,
|
|
85
|
+
totalCount
|
|
86
|
+
`;
|
|
87
|
+
const result = await session.run(expandQuery, {
|
|
88
|
+
nodeId,
|
|
89
|
+
accountId,
|
|
90
|
+
cap: NODE_CAP,
|
|
91
|
+
});
|
|
92
|
+
if (result.records.length === 0) {
|
|
93
|
+
logLine(`[graph-render] reject account=${accountId} reason=empty nodeId=${nodeId}`);
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
reason: "empty",
|
|
97
|
+
message: "Root node found but its neighbourhood is empty after trash filter.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const record = result.records[0];
|
|
101
|
+
const nodeRows = record.get("nodeRows");
|
|
102
|
+
const edgeRowsRaw = record.get("edgeRows");
|
|
103
|
+
const totalCountRaw = record.get("totalCount");
|
|
104
|
+
const totalCount = typeof totalCountRaw === "number"
|
|
105
|
+
? totalCountRaw
|
|
106
|
+
: (totalCountRaw?.toNumber?.() ?? nodeRows.length);
|
|
107
|
+
// OPTIONAL MATCH on no rels produces one {from:null,to:null,type:null}
|
|
108
|
+
// row from collect; strip those.
|
|
109
|
+
const edges = edgeRowsRaw
|
|
110
|
+
.filter((e) => e.from && e.to && e.type)
|
|
111
|
+
.map((e) => ({ from: e.from, to: e.to, type: e.type }));
|
|
112
|
+
if (nodeRows.length === 0) {
|
|
113
|
+
logLine(`[graph-render] reject account=${accountId} reason=empty nodeId=${nodeId}`);
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
reason: "empty",
|
|
117
|
+
message: "Subgraph contained no nodes after trash filter.",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const layoutNodes = nodeRows.map((n) => ({
|
|
121
|
+
id: n.id,
|
|
122
|
+
labels: n.labels,
|
|
123
|
+
}));
|
|
124
|
+
const nodeProperties = {};
|
|
125
|
+
for (const n of nodeRows)
|
|
126
|
+
nodeProperties[n.id] = n.properties;
|
|
127
|
+
const layout = layoutGraph(layoutNodes, edges, {
|
|
128
|
+
width: IMAGE_WIDTH,
|
|
129
|
+
height: IMAGE_HEIGHT,
|
|
130
|
+
});
|
|
131
|
+
let pngBuffer;
|
|
132
|
+
try {
|
|
133
|
+
pngBuffer = drawGraph({
|
|
134
|
+
layout,
|
|
135
|
+
nodeProperties,
|
|
136
|
+
width: IMAGE_WIDTH,
|
|
137
|
+
height: IMAGE_HEIGHT,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
142
|
+
logLine(`[graph-render] reject account=${accountId} reason=draw-error nodeId=${nodeId} cause=${message}`);
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
reason: "draw-error",
|
|
146
|
+
message: `Canvas draw failed: ${message}`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const truncated = totalCount > nodeRows.length
|
|
150
|
+
? { rendered: nodeRows.length, total: totalCount }
|
|
151
|
+
: undefined;
|
|
152
|
+
const ms = Date.now() - t0;
|
|
153
|
+
logLine(`[graph-render] ok account=${accountId} ms=${ms} bytes=${pngBuffer.length} ` +
|
|
154
|
+
`nodeCount=${nodeRows.length} edgeCount=${edges.length} ` +
|
|
155
|
+
`truncated=${truncated ? "true" : "false"} iterations=${layout.iterations}`);
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
pngBase64: pngBuffer.toString("base64"),
|
|
159
|
+
width: IMAGE_WIDTH,
|
|
160
|
+
height: IMAGE_HEIGHT,
|
|
161
|
+
nodeCount: nodeRows.length,
|
|
162
|
+
edgeCount: edges.length,
|
|
163
|
+
...(truncated ? { truncated } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await session.close();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=graph-render.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"graph-render.js","sourceRoot":"","sources":["../../src/tools/graph-render.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maxy/graph-viewer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"start": "node dist/index.js",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
14
|
+
"@napi-rs/canvas": "^0.1.56",
|
|
15
|
+
"d3-force": "^3.0.0",
|
|
16
|
+
"neo4j-driver": "^5.28.1",
|
|
17
|
+
"zod": "^3.23.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/d3-force": "^3.0.10",
|
|
21
|
+
"@types/node": "^22.0.0",
|
|
22
|
+
"typescript": "^5.7.0",
|
|
23
|
+
"vitest": "^4.1.2"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: render-graph
|
|
3
|
+
description: "Render an inline PNG of a Neo4j subgraph for the operator using the graph-render MCP tool. Use when the operator asks to 'see the graph', 'show me the subgraph around X', 'render the owner subgraph', or any request whose outcome is a picture of nodes-and-edges they can look at in chat. Never substitute Mermaid, ASCII, or any other graph stand-in: graph-render is the only correct surface, and if it refuses (foreign account, missing node, empty neighbourhood, draw error) surface the structured error to the operator and stop. The image uses the same palette and short-label strings as the admin /graph page; node positions will differ because the renderer is d3-force not vis-network."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# render-graph
|
|
7
|
+
|
|
8
|
+
When the operator asks for a picture of a subgraph:
|
|
9
|
+
|
|
10
|
+
1. Resolve the root node's `elementId` first — usually via `memory-search`
|
|
11
|
+
or another graph tool the operator already pointed you at. Never pass a
|
|
12
|
+
property like `name` as `nodeId`.
|
|
13
|
+
2. Pick a `hopDepth` between 0 and 3. Default to 1 unless the operator
|
|
14
|
+
asks for the wider neighbourhood. Depth 3 on a busy node will hit the
|
|
15
|
+
200-node cap and return a `truncated` envelope.
|
|
16
|
+
3. Call `graph-render` with `{ accountId, nodeId, hopDepth }`.
|
|
17
|
+
4. If the result is `ok: true`, the image attaches itself to your reply.
|
|
18
|
+
Tell the operator how many nodes are in view and, if `truncated` is
|
|
19
|
+
present, that there were more nodes than the cap.
|
|
20
|
+
5. If the result is `ok: false`, surface the structured `reason` and
|
|
21
|
+
`message` to the operator verbatim. Do not retry with a different
|
|
22
|
+
nodeId without their input. Do not fall back to Mermaid, ASCII, or any
|
|
23
|
+
other graph substitute.
|
|
24
|
+
|
|
25
|
+
## Reasons graph-render can refuse
|
|
26
|
+
|
|
27
|
+
- `scope` — the nodeId belongs to a different account, or does not exist.
|
|
28
|
+
- `empty` — the root exists but its neighbourhood is empty after the
|
|
29
|
+
trash filter.
|
|
30
|
+
- `cap-exceeded` — reserved for future use; today subgraphs above 200
|
|
31
|
+
nodes return `ok: true` with a `truncated` envelope.
|
|
32
|
+
- `draw-error` — the canvas renderer failed. The message contains the
|
|
33
|
+
underlying error; if it mentions a missing font, the system font fallback
|
|
34
|
+
is unavailable on this device.
|
|
35
|
+
- `invalid-input` — `hopDepth` was outside the 0-3 range.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Publish-time gate: every PLUGIN.md with a non-empty `tools:` frontmatter
|
|
3
|
+
// block must also declare an `mcp:` block. The `tools:` block populates the
|
|
4
|
+
// `--allowed-tools mcp__<plugin>__*` spawn argv; without a registered MCP
|
|
5
|
+
// server the agent boots with permissions for tools that have no backend.
|
|
6
|
+
//
|
|
7
|
+
// The session manager throws the same defect at boot
|
|
8
|
+
// (tool-surface.ts 'mcp-block-missing'), but boot is on the operator's
|
|
9
|
+
// device — by then the bad payload has already been published and installed.
|
|
10
|
+
// This gate fires before `npm publish`, in the prepublishOnly chain.
|
|
11
|
+
//
|
|
12
|
+
// Live repro: Task 357 added visitor-graph tools to buyers/PLUGIN.md without
|
|
13
|
+
// the matching mcp: block; the slip-through is what motivates Task 368.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
|
|
16
|
+
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
20
|
+
const MAXY_CODE_ROOT = resolve(__dirname, '..', '..')
|
|
21
|
+
|
|
22
|
+
const PLUGIN_ROOTS = [
|
|
23
|
+
// platform/plugins/<plugin>/PLUGIN.md
|
|
24
|
+
{ root: join(MAXY_CODE_ROOT, 'platform', 'plugins'), depth: 1 },
|
|
25
|
+
// premium-plugins/<bundle>/PLUGIN.md (shape b)
|
|
26
|
+
{ root: join(MAXY_CODE_ROOT, 'premium-plugins'), depth: 1 },
|
|
27
|
+
// premium-plugins/<bundle>/plugins/<sub>/PLUGIN.md (shape a)
|
|
28
|
+
{ root: join(MAXY_CODE_ROOT, 'premium-plugins'), depth: 2, mid: 'plugins' },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
function collectPluginDirs() {
|
|
32
|
+
const dirs = []
|
|
33
|
+
for (const spec of PLUGIN_ROOTS) {
|
|
34
|
+
if (!existsSync(spec.root)) continue
|
|
35
|
+
for (const entry of readdirSync(spec.root)) {
|
|
36
|
+
if (entry.startsWith('.')) continue
|
|
37
|
+
const lvl1 = join(spec.root, entry)
|
|
38
|
+
if (!statSync(lvl1).isDirectory()) continue
|
|
39
|
+
if (spec.depth === 1) {
|
|
40
|
+
dirs.push(lvl1)
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
const midDir = join(lvl1, spec.mid)
|
|
44
|
+
if (!existsSync(midDir)) continue
|
|
45
|
+
if (!statSync(midDir).isDirectory()) continue
|
|
46
|
+
for (const sub of readdirSync(midDir)) {
|
|
47
|
+
if (sub.startsWith('.')) continue
|
|
48
|
+
const lvl2 = join(midDir, sub)
|
|
49
|
+
if (!statSync(lvl2).isDirectory()) continue
|
|
50
|
+
dirs.push(lvl2)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return dirs
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function extractFrontmatter(md) {
|
|
58
|
+
if (!md.startsWith('---')) return null
|
|
59
|
+
const end = md.indexOf('\n---', 3)
|
|
60
|
+
if (end < 0) return null
|
|
61
|
+
return md.slice(3, end)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Detect a top-level key in YAML frontmatter (key at column 0, followed by `:`).
|
|
65
|
+
// The key may be bare (`tools:`) — empty value means "block follows on
|
|
66
|
+
// subsequent indented lines" OR "empty list".
|
|
67
|
+
function findTopLevelKey(frontmatter, key) {
|
|
68
|
+
const re = new RegExp(`^${key}\\s*:`, 'm')
|
|
69
|
+
return re.test(frontmatter)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Returns true when the `tools:` block has at least one list entry. A bare
|
|
73
|
+
// `tools:` with no children, or `tools: []`, is treated as empty (no surface).
|
|
74
|
+
function hasNonEmptyToolsBlock(frontmatter) {
|
|
75
|
+
const lines = frontmatter.split('\n')
|
|
76
|
+
let inTools = false
|
|
77
|
+
for (let i = 0; i < lines.length; i++) {
|
|
78
|
+
const line = lines[i]
|
|
79
|
+
if (/^tools\s*:/.test(line)) {
|
|
80
|
+
const after = line.replace(/^tools\s*:/, '').trim()
|
|
81
|
+
// Inline form: `tools: []` or `tools: [a, b]`
|
|
82
|
+
if (after.startsWith('[')) {
|
|
83
|
+
return /\[\s*[^\]\s]/.test(after)
|
|
84
|
+
}
|
|
85
|
+
if (after) {
|
|
86
|
+
// Single-line scalar (unusual for tools, but treat as non-empty).
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
inTools = true
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (inTools) {
|
|
93
|
+
if (/^\S/.test(line) && line.trim() !== '') {
|
|
94
|
+
// Top-level key after tools: — block ended.
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
if (/^\s*-\s+/.test(line)) return true
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function checkPlugin(pluginDir) {
|
|
104
|
+
const manifestPath = join(pluginDir, 'PLUGIN.md')
|
|
105
|
+
if (!existsSync(manifestPath)) return null
|
|
106
|
+
const raw = readFileSync(manifestPath, 'utf-8')
|
|
107
|
+
const fm = extractFrontmatter(raw)
|
|
108
|
+
if (fm === null) return null
|
|
109
|
+
if (!hasNonEmptyToolsBlock(fm)) return null
|
|
110
|
+
if (findTopLevelKey(fm, 'mcp')) return null
|
|
111
|
+
return manifestPath
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function main() {
|
|
115
|
+
const offenders = []
|
|
116
|
+
for (const dir of collectPluginDirs()) {
|
|
117
|
+
const offender = checkPlugin(dir)
|
|
118
|
+
if (offender) offenders.push({ dir, manifestPath: offender })
|
|
119
|
+
}
|
|
120
|
+
if (offenders.length === 0) {
|
|
121
|
+
console.log('[gate] check-plugin-tools-mcp-consistency: PASS')
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
for (const { dir, manifestPath } of offenders) {
|
|
125
|
+
const plugin = dir.split('/').pop()
|
|
126
|
+
console.error(
|
|
127
|
+
`[gate] FATAL: plugin=${plugin} has tools: block but no mcp: block.\n` +
|
|
128
|
+
` manifest: ${manifestPath}\n` +
|
|
129
|
+
` Spawns would pass --allowed-tools mcp__${plugin}__* with no registered server.\n` +
|
|
130
|
+
` Add mcp: block or remove tools: entries.`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
process.exit(1)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
main()
|