@neat.is/mcp 0.2.5
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.cjs +775 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +776 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
var import_mcp2 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
6
|
+
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
7
|
+
var import_zod = require("zod");
|
|
8
|
+
var import_types2 = require("@neat.is/types");
|
|
9
|
+
|
|
10
|
+
// src/client.ts
|
|
11
|
+
function createHttpClient(baseUrl2) {
|
|
12
|
+
const root = baseUrl2.replace(/\/$/, "");
|
|
13
|
+
return {
|
|
14
|
+
async get(path) {
|
|
15
|
+
const res = await fetch(`${root}${path}`);
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
const body = await res.text().catch(() => "");
|
|
18
|
+
throw new HttpError(res.status, `${res.status} ${res.statusText} on GET ${path}: ${body}`);
|
|
19
|
+
}
|
|
20
|
+
return await res.json();
|
|
21
|
+
},
|
|
22
|
+
async post(path, body) {
|
|
23
|
+
const res = await fetch(`${root}${path}`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "content-type": "application/json" },
|
|
26
|
+
body: JSON.stringify(body)
|
|
27
|
+
});
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const text = await res.text().catch(() => "");
|
|
30
|
+
throw new HttpError(res.status, `${res.status} ${res.statusText} on POST ${path}: ${text}`);
|
|
31
|
+
}
|
|
32
|
+
return await res.json();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
var HttpError = class extends Error {
|
|
37
|
+
constructor(status, message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.status = status;
|
|
40
|
+
this.name = "HttpError";
|
|
41
|
+
}
|
|
42
|
+
status;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// src/resources.ts
|
|
46
|
+
var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
47
|
+
var NODE_RESOURCE_MIME = "application/json";
|
|
48
|
+
var INCIDENTS_URI = "neat://incidents/recent";
|
|
49
|
+
var INCIDENTS_DEFAULT_LIMIT = 50;
|
|
50
|
+
var POLICY_VIOLATIONS_URI = "neat://policies/violations";
|
|
51
|
+
var POLICY_VIOLATIONS_DEFAULT_LIMIT = 100;
|
|
52
|
+
function nodeUri(id) {
|
|
53
|
+
return `neat://node/${encodeURIComponent(id)}`;
|
|
54
|
+
}
|
|
55
|
+
function corePrefix(project) {
|
|
56
|
+
return project ? `/projects/${encodeURIComponent(project)}` : "";
|
|
57
|
+
}
|
|
58
|
+
function nameFromAttrs(attrs) {
|
|
59
|
+
return attrs.name ?? attrs.id;
|
|
60
|
+
}
|
|
61
|
+
async function listNodeResources(client2, project) {
|
|
62
|
+
const graph = await client2.get(`${corePrefix(project)}/graph`);
|
|
63
|
+
return {
|
|
64
|
+
resources: graph.nodes.map((n) => ({
|
|
65
|
+
uri: nodeUri(n.id),
|
|
66
|
+
name: nameFromAttrs(n),
|
|
67
|
+
description: `${n.type} \u2014 ${nameFromAttrs(n)}`,
|
|
68
|
+
mimeType: NODE_RESOURCE_MIME
|
|
69
|
+
}))
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function readNodeResource(client2, id, project) {
|
|
73
|
+
const uri = nodeUri(id);
|
|
74
|
+
const prefix = corePrefix(project);
|
|
75
|
+
try {
|
|
76
|
+
const [attrs, edges] = await Promise.all([
|
|
77
|
+
client2.get(`${prefix}/graph/node/${encodeURIComponent(id)}`),
|
|
78
|
+
client2.get(`${prefix}/graph/edges/${encodeURIComponent(id)}`)
|
|
79
|
+
]);
|
|
80
|
+
const body = {
|
|
81
|
+
node: attrs,
|
|
82
|
+
// Outbound only — the issue spec says "attrs + outbound edges". Inbound
|
|
83
|
+
// edges are still reachable via the other endpoint and would double the
|
|
84
|
+
// payload for hub nodes (e.g. a shared database).
|
|
85
|
+
outboundEdges: edges.outbound
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
contents: [
|
|
89
|
+
{
|
|
90
|
+
uri,
|
|
91
|
+
mimeType: NODE_RESOURCE_MIME,
|
|
92
|
+
text: JSON.stringify(body, null, 2)
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
};
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err instanceof HttpError && err.status === 404) {
|
|
98
|
+
return {
|
|
99
|
+
contents: [
|
|
100
|
+
{
|
|
101
|
+
uri,
|
|
102
|
+
mimeType: NODE_RESOURCE_MIME,
|
|
103
|
+
text: JSON.stringify({ error: "node not found", id })
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function readPolicyViolationsResource(client2, limit = POLICY_VIOLATIONS_DEFAULT_LIMIT, project) {
|
|
112
|
+
const violations = await client2.get(
|
|
113
|
+
`${corePrefix(project)}/policies/violations`
|
|
114
|
+
);
|
|
115
|
+
const ordered = [...violations].reverse().slice(0, limit);
|
|
116
|
+
return {
|
|
117
|
+
contents: [
|
|
118
|
+
{
|
|
119
|
+
uri: POLICY_VIOLATIONS_URI,
|
|
120
|
+
mimeType: NODE_RESOURCE_MIME,
|
|
121
|
+
text: JSON.stringify(
|
|
122
|
+
{ count: ordered.length, total: violations.length, violations: ordered },
|
|
123
|
+
null,
|
|
124
|
+
2
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
async function readRecentIncidentsResource(client2, limit = INCIDENTS_DEFAULT_LIMIT, project) {
|
|
131
|
+
const events = await client2.get(`${corePrefix(project)}/incidents`);
|
|
132
|
+
const ordered = [...events].reverse().slice(0, limit);
|
|
133
|
+
return {
|
|
134
|
+
contents: [
|
|
135
|
+
{
|
|
136
|
+
uri: INCIDENTS_URI,
|
|
137
|
+
mimeType: NODE_RESOURCE_MIME,
|
|
138
|
+
text: JSON.stringify(
|
|
139
|
+
{ count: ordered.length, total: events.length, events: ordered },
|
|
140
|
+
null,
|
|
141
|
+
2
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function incidentsChanged(prev, next) {
|
|
148
|
+
if (!prev) return false;
|
|
149
|
+
if (prev.total !== next.total) return true;
|
|
150
|
+
if (prev.lastId !== next.lastId) return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
function registerResources(server2, client2, options = {}) {
|
|
154
|
+
const pollMs = options.incidentsPollMs ?? 5e3;
|
|
155
|
+
const project = options.project;
|
|
156
|
+
server2.registerResource(
|
|
157
|
+
"graph-node",
|
|
158
|
+
new import_mcp.ResourceTemplate("neat://node/{id}", {
|
|
159
|
+
list: async () => listNodeResources(client2, project)
|
|
160
|
+
}),
|
|
161
|
+
{
|
|
162
|
+
description: "A single graph node by id. Reading returns the node attributes plus its outbound edges as JSON.",
|
|
163
|
+
mimeType: NODE_RESOURCE_MIME
|
|
164
|
+
},
|
|
165
|
+
async (_uri, variables) => {
|
|
166
|
+
const raw = variables.id;
|
|
167
|
+
const id = Array.isArray(raw) ? raw[0] : raw;
|
|
168
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
169
|
+
throw new Error("neat://node/{id} requires an id");
|
|
170
|
+
}
|
|
171
|
+
const decoded = id.includes("%") ? decodeURIComponent(id) : id;
|
|
172
|
+
return readNodeResource(client2, decoded, project);
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
server2.registerResource(
|
|
176
|
+
"incidents-recent",
|
|
177
|
+
INCIDENTS_URI,
|
|
178
|
+
{
|
|
179
|
+
description: "Most recent error events recorded by neat-core, newest first. JSON: { count, total, events[] }.",
|
|
180
|
+
mimeType: NODE_RESOURCE_MIME
|
|
181
|
+
},
|
|
182
|
+
async () => readRecentIncidentsResource(client2, INCIDENTS_DEFAULT_LIMIT, project)
|
|
183
|
+
);
|
|
184
|
+
server2.registerResource(
|
|
185
|
+
"policies-violations",
|
|
186
|
+
POLICY_VIOLATIONS_URI,
|
|
187
|
+
{
|
|
188
|
+
description: "Current policy violations from policy-violations.ndjson, newest first. JSON: { count, total, violations[] }.",
|
|
189
|
+
mimeType: NODE_RESOURCE_MIME
|
|
190
|
+
},
|
|
191
|
+
async () => readPolicyViolationsResource(client2, POLICY_VIOLATIONS_DEFAULT_LIMIT, project)
|
|
192
|
+
);
|
|
193
|
+
let stopped = false;
|
|
194
|
+
let timer = null;
|
|
195
|
+
let lastIncidents = null;
|
|
196
|
+
let lastViolations = null;
|
|
197
|
+
const tick = async () => {
|
|
198
|
+
if (stopped) return;
|
|
199
|
+
try {
|
|
200
|
+
const events = await client2.get(`${corePrefix(project)}/incidents`);
|
|
201
|
+
const next = {
|
|
202
|
+
total: events.length,
|
|
203
|
+
lastId: events.length > 0 ? events[events.length - 1].id : void 0
|
|
204
|
+
};
|
|
205
|
+
if (incidentsChanged(lastIncidents, next)) {
|
|
206
|
+
await server2.server.sendResourceUpdated({ uri: INCIDENTS_URI }).catch(() => {
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
lastIncidents = next;
|
|
210
|
+
} catch {
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const violations = await client2.get(
|
|
214
|
+
`${corePrefix(project)}/policies/violations`
|
|
215
|
+
);
|
|
216
|
+
const next = {
|
|
217
|
+
total: violations.length,
|
|
218
|
+
lastId: violations.length > 0 ? violations[violations.length - 1].id : void 0
|
|
219
|
+
};
|
|
220
|
+
if (incidentsChanged(lastViolations, next)) {
|
|
221
|
+
await server2.server.sendResourceUpdated({ uri: POLICY_VIOLATIONS_URI }).catch(() => {
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
lastViolations = next;
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
if (pollMs > 0) {
|
|
229
|
+
timer = setInterval(() => {
|
|
230
|
+
void tick();
|
|
231
|
+
}, pollMs);
|
|
232
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
stop: () => {
|
|
236
|
+
stopped = true;
|
|
237
|
+
if (timer) clearInterval(timer);
|
|
238
|
+
timer = null;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/tools.ts
|
|
244
|
+
var import_types = require("@neat.is/types");
|
|
245
|
+
|
|
246
|
+
// src/format.ts
|
|
247
|
+
function formatFooter(confidence, provenance) {
|
|
248
|
+
const c = confidence === void 0 ? "n/a" : confidence.toFixed(2);
|
|
249
|
+
const p = provenance === void 0 ? "n/a" : Array.isArray(provenance) ? [...new Set(provenance)].join(", ") : provenance;
|
|
250
|
+
return `confidence: ${c} \xB7 provenance: ${p}`;
|
|
251
|
+
}
|
|
252
|
+
function formatToolResponse(input) {
|
|
253
|
+
const sections = [input.summary.trim()];
|
|
254
|
+
if (input.block && input.block.trim().length > 0) {
|
|
255
|
+
sections.push(input.block.trimEnd());
|
|
256
|
+
}
|
|
257
|
+
sections.push(formatFooter(input.confidence, input.provenance));
|
|
258
|
+
const text = sections.join("\n\n");
|
|
259
|
+
return {
|
|
260
|
+
content: [{ type: "text", text }],
|
|
261
|
+
...input.isError ? { isError: true } : {}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function formatEmptyResponse(summary) {
|
|
265
|
+
return formatToolResponse({ summary });
|
|
266
|
+
}
|
|
267
|
+
function formatErrorResponse(message) {
|
|
268
|
+
return formatToolResponse({ summary: message, isError: true });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/tools.ts
|
|
272
|
+
function projectPath(project, suffix) {
|
|
273
|
+
if (!project) return suffix;
|
|
274
|
+
return `/projects/${encodeURIComponent(project)}${suffix}`;
|
|
275
|
+
}
|
|
276
|
+
async function withMissingNodeFallback(fn, notFoundMessage) {
|
|
277
|
+
try {
|
|
278
|
+
return await fn();
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (err instanceof HttpError && err.status === 404) {
|
|
281
|
+
return formatEmptyResponse(notFoundMessage);
|
|
282
|
+
}
|
|
283
|
+
return formatErrorResponse(`Error talking to neat-core: ${err.message}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function getRootCause(client2, input) {
|
|
287
|
+
const qs = input.errorId ? `?errorId=${encodeURIComponent(input.errorId)}` : "";
|
|
288
|
+
const path = projectPath(
|
|
289
|
+
input.project,
|
|
290
|
+
`/traverse/root-cause/${encodeURIComponent(input.errorNode)}${qs}`
|
|
291
|
+
);
|
|
292
|
+
return withMissingNodeFallback(async () => {
|
|
293
|
+
const result = await client2.get(path);
|
|
294
|
+
const arrowPath = result.traversalPath.join(" \u2190 ");
|
|
295
|
+
const provenances = result.edgeProvenances.length ? result.edgeProvenances.join(", ") : "(direct, no edges traversed)";
|
|
296
|
+
const summary = `Root cause for ${input.errorNode} is ${result.rootCauseNode}. ` + result.rootCauseReason + (result.fixRecommendation ? ` Recommended fix: ${result.fixRecommendation}.` : "");
|
|
297
|
+
const blockLines = [
|
|
298
|
+
`Traversal path: ${arrowPath}`,
|
|
299
|
+
`Edge provenances: ${provenances}`
|
|
300
|
+
];
|
|
301
|
+
if (result.fixRecommendation) {
|
|
302
|
+
blockLines.push(`Recommended fix: ${result.fixRecommendation}`);
|
|
303
|
+
}
|
|
304
|
+
return formatToolResponse({
|
|
305
|
+
summary,
|
|
306
|
+
block: blockLines.join("\n"),
|
|
307
|
+
confidence: result.confidence,
|
|
308
|
+
provenance: result.edgeProvenances.length ? result.edgeProvenances : void 0
|
|
309
|
+
});
|
|
310
|
+
}, `No root cause found for ${input.errorNode}. The node may be healthy, or it may not exist in the graph.`);
|
|
311
|
+
}
|
|
312
|
+
async function getBlastRadius(client2, input) {
|
|
313
|
+
const qs = input.depth !== void 0 ? `?depth=${input.depth}` : "";
|
|
314
|
+
const path = projectPath(
|
|
315
|
+
input.project,
|
|
316
|
+
`/traverse/blast-radius/${encodeURIComponent(input.nodeId)}${qs}`
|
|
317
|
+
);
|
|
318
|
+
return withMissingNodeFallback(async () => {
|
|
319
|
+
const result = await client2.get(path);
|
|
320
|
+
if (result.totalAffected === 0) {
|
|
321
|
+
return formatEmptyResponse(
|
|
322
|
+
`${result.origin} has no downstream dependencies. Nothing else would break if it failed.`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const sorted = [...result.affectedNodes].sort(
|
|
326
|
+
(a, b) => a.distance - b.distance || a.nodeId.localeCompare(b.nodeId)
|
|
327
|
+
);
|
|
328
|
+
const blockLines = sorted.map(formatBlastEntry);
|
|
329
|
+
const minConfidence = sorted.reduce(
|
|
330
|
+
(m, n) => Math.min(m, n.confidence),
|
|
331
|
+
Number.POSITIVE_INFINITY
|
|
332
|
+
);
|
|
333
|
+
const provenances = [...new Set(sorted.map((n) => n.edgeProvenance))];
|
|
334
|
+
return formatToolResponse({
|
|
335
|
+
summary: `Blast radius for ${result.origin}: ${result.totalAffected} affected node${result.totalAffected === 1 ? "" : "s"} reachable downstream.`,
|
|
336
|
+
block: blockLines.join("\n"),
|
|
337
|
+
confidence: Number.isFinite(minConfidence) ? minConfidence : void 0,
|
|
338
|
+
provenance: provenances.length ? provenances : void 0
|
|
339
|
+
});
|
|
340
|
+
}, `Node ${input.nodeId} not found in the graph.`);
|
|
341
|
+
}
|
|
342
|
+
function formatBlastEntry(n) {
|
|
343
|
+
const tag = n.edgeProvenance === import_types.Provenance.STALE ? " [STALE \u2014 last seen too long ago]" : "";
|
|
344
|
+
return ` \u2022 ${n.nodeId} (distance ${n.distance}, ${n.edgeProvenance})${tag}`;
|
|
345
|
+
}
|
|
346
|
+
async function getDependencies(client2, input) {
|
|
347
|
+
const depth = input.depth ?? 3;
|
|
348
|
+
const path = projectPath(
|
|
349
|
+
input.project,
|
|
350
|
+
`/graph/node/${encodeURIComponent(input.nodeId)}/dependencies?depth=${depth}`
|
|
351
|
+
);
|
|
352
|
+
return withMissingNodeFallback(async () => {
|
|
353
|
+
const result = await client2.get(path);
|
|
354
|
+
if (result.total === 0) {
|
|
355
|
+
return formatEmptyResponse(
|
|
356
|
+
depth === 1 ? `${input.nodeId} has no direct dependencies in the graph.` : `${input.nodeId} has no dependencies (BFS to depth ${depth}).`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const byDistance = /* @__PURE__ */ new Map();
|
|
360
|
+
for (const dep of result.dependencies) {
|
|
361
|
+
const ring = byDistance.get(dep.distance) ?? [];
|
|
362
|
+
ring.push(dep);
|
|
363
|
+
byDistance.set(dep.distance, ring);
|
|
364
|
+
}
|
|
365
|
+
const blockLines = [];
|
|
366
|
+
for (const distance of [...byDistance.keys()].sort((a, b) => a - b)) {
|
|
367
|
+
const label = distance === 1 ? "Direct (distance 1)" : `Distance ${distance}`;
|
|
368
|
+
blockLines.push(`${label}:`);
|
|
369
|
+
for (const dep of byDistance.get(distance)) {
|
|
370
|
+
blockLines.push(` \u2022 ${dep.nodeId} \u2014 ${dep.edgeType} (${dep.provenance})`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const provenances = [...new Set(result.dependencies.map((d) => d.provenance))];
|
|
374
|
+
const directCount = byDistance.get(1)?.length ?? 0;
|
|
375
|
+
const summary = depth === 1 ? `${input.nodeId} has ${directCount} direct dependenc${directCount === 1 ? "y" : "ies"}.` : `${input.nodeId} has ${result.total} dependenc${result.total === 1 ? "y" : "ies"} reachable to depth ${depth} (${directCount} direct).`;
|
|
376
|
+
return formatToolResponse({
|
|
377
|
+
summary,
|
|
378
|
+
block: blockLines.join("\n"),
|
|
379
|
+
provenance: provenances
|
|
380
|
+
});
|
|
381
|
+
}, `Node ${input.nodeId} not found in the graph.`);
|
|
382
|
+
}
|
|
383
|
+
async function getObservedDependencies(client2, input) {
|
|
384
|
+
return withMissingNodeFallback(async () => {
|
|
385
|
+
const edges = await client2.get(
|
|
386
|
+
projectPath(input.project, `/graph/edges/${encodeURIComponent(input.nodeId)}`)
|
|
387
|
+
);
|
|
388
|
+
const observed = edges.outbound.filter((e) => e.provenance === import_types.Provenance.OBSERVED);
|
|
389
|
+
if (observed.length === 0) {
|
|
390
|
+
const hasExtracted = edges.outbound.some((e) => e.provenance === import_types.Provenance.EXTRACTED);
|
|
391
|
+
const note = hasExtracted ? " Static (EXTRACTED) dependencies exist but no runtime traffic has been seen \u2014 is OTel running?" : "";
|
|
392
|
+
return formatEmptyResponse(`No OBSERVED dependencies for ${input.nodeId}.${note}`);
|
|
393
|
+
}
|
|
394
|
+
const blockLines = observed.map((e) => ` \u2022 ${e.target} \u2014 ${e.type}${edgeMeta(e)}`);
|
|
395
|
+
return formatToolResponse({
|
|
396
|
+
summary: `${input.nodeId} has ${observed.length} runtime dependenc${observed.length === 1 ? "y" : "ies"} confirmed by OTel.`,
|
|
397
|
+
block: blockLines.join("\n"),
|
|
398
|
+
provenance: import_types.Provenance.OBSERVED
|
|
399
|
+
});
|
|
400
|
+
}, `Node ${input.nodeId} not found in the graph.`);
|
|
401
|
+
}
|
|
402
|
+
function edgeMeta(e) {
|
|
403
|
+
const bits = [];
|
|
404
|
+
if (e.signal) {
|
|
405
|
+
bits.push(`spans=${e.signal.spanCount}`);
|
|
406
|
+
if (e.signal.errorCount > 0) bits.push(`errors=${e.signal.errorCount}`);
|
|
407
|
+
if (e.signal.lastObservedAgeMs !== void 0) {
|
|
408
|
+
bits.push(`age=${formatDuration(e.signal.lastObservedAgeMs)}`);
|
|
409
|
+
}
|
|
410
|
+
} else if (e.callCount !== void 0) {
|
|
411
|
+
bits.push(`callCount=${e.callCount}`);
|
|
412
|
+
}
|
|
413
|
+
if (e.lastObserved) bits.push(`lastObserved=${e.lastObserved}`);
|
|
414
|
+
if (e.confidence !== void 0) bits.push(`confidence=${e.confidence}`);
|
|
415
|
+
return bits.length ? ` [${bits.join(", ")}]` : "";
|
|
416
|
+
}
|
|
417
|
+
function formatDuration(ms) {
|
|
418
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
419
|
+
const s = Math.round(ms / 1e3);
|
|
420
|
+
if (s < 60) return `${s}s`;
|
|
421
|
+
const m = Math.round(s / 60);
|
|
422
|
+
if (m < 60) return `${m}m`;
|
|
423
|
+
const h = Math.round(m / 60);
|
|
424
|
+
if (h < 48) return `${h}h`;
|
|
425
|
+
return `${Math.round(h / 24)}d`;
|
|
426
|
+
}
|
|
427
|
+
async function getIncidentHistory(client2, input) {
|
|
428
|
+
return withMissingNodeFallback(async () => {
|
|
429
|
+
const events = await client2.get(
|
|
430
|
+
projectPath(input.project, `/incidents/${encodeURIComponent(input.nodeId)}`)
|
|
431
|
+
);
|
|
432
|
+
if (events.length === 0) {
|
|
433
|
+
return formatEmptyResponse(`No incidents recorded against ${input.nodeId}.`);
|
|
434
|
+
}
|
|
435
|
+
const ordered = [...events].reverse().slice(0, input.limit ?? 20);
|
|
436
|
+
const blockLines = [];
|
|
437
|
+
for (const ev of ordered) {
|
|
438
|
+
blockLines.push(` ${ev.timestamp} \u2014 ${ev.service}: ${ev.errorMessage}`);
|
|
439
|
+
blockLines.push(` trace=${ev.traceId} span=${ev.spanId}`);
|
|
440
|
+
}
|
|
441
|
+
return formatToolResponse({
|
|
442
|
+
summary: `${input.nodeId} has ${events.length} recorded incident${events.length === 1 ? "" : "s"}; showing the ${ordered.length} most recent.`,
|
|
443
|
+
block: blockLines.join("\n"),
|
|
444
|
+
// ErrorEvents are observation records, not graph edges — provenance is
|
|
445
|
+
// OBSERVED by definition (the OTel span happened).
|
|
446
|
+
provenance: import_types.Provenance.OBSERVED
|
|
447
|
+
});
|
|
448
|
+
}, `Node ${input.nodeId} not found in the graph.`);
|
|
449
|
+
}
|
|
450
|
+
async function semanticSearch(client2, input) {
|
|
451
|
+
try {
|
|
452
|
+
const result = await client2.get(
|
|
453
|
+
projectPath(input.project, `/search?q=${encodeURIComponent(input.query)}`)
|
|
454
|
+
);
|
|
455
|
+
if (result.matches.length === 0) {
|
|
456
|
+
return formatEmptyResponse(`No matches for "${input.query}".`);
|
|
457
|
+
}
|
|
458
|
+
const provider = result.provider ?? "substring";
|
|
459
|
+
const blockLines = [];
|
|
460
|
+
let topScore;
|
|
461
|
+
for (const n of result.matches) {
|
|
462
|
+
const score = provider !== "substring" && typeof n.score === "number" ? n.score : void 0;
|
|
463
|
+
const scoreBit = score !== void 0 ? ` [score=${score.toFixed(2)}]` : "";
|
|
464
|
+
if (score !== void 0 && (topScore === void 0 || score > topScore)) topScore = score;
|
|
465
|
+
blockLines.push(
|
|
466
|
+
` \u2022 ${n.id} (${n.type}) \u2014 ${n.name ?? n.id}${scoreBit}`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
return formatToolResponse({
|
|
470
|
+
summary: `Found ${result.matches.length} match${result.matches.length === 1 ? "" : "es"} for "${input.query}" via ${provider} provider.`,
|
|
471
|
+
block: blockLines.join("\n"),
|
|
472
|
+
// Top similarity score doubles as a "how confident is the embedder
|
|
473
|
+
// about the best match" signal. Substring provider returns no score —
|
|
474
|
+
// the footer shows n/a in that case.
|
|
475
|
+
confidence: topScore
|
|
476
|
+
});
|
|
477
|
+
} catch (err) {
|
|
478
|
+
return formatErrorResponse(`Error talking to neat-core: ${err.message}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async function getGraphDiff(client2, input) {
|
|
482
|
+
try {
|
|
483
|
+
const result = await client2.get(
|
|
484
|
+
projectPath(
|
|
485
|
+
input.project,
|
|
486
|
+
`/graph/diff?against=${encodeURIComponent(input.againstSnapshot)}`
|
|
487
|
+
)
|
|
488
|
+
);
|
|
489
|
+
const total = result.added.nodes.length + result.added.edges.length + result.removed.nodes.length + result.removed.edges.length + result.changed.nodes.length + result.changed.edges.length;
|
|
490
|
+
const baseLabel = result.base.exportedAt ?? "unknown";
|
|
491
|
+
if (total === 0) {
|
|
492
|
+
return formatEmptyResponse(
|
|
493
|
+
`No differences between the current graph and ${input.againstSnapshot} (base exportedAt=${baseLabel}).`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
const blockLines = [
|
|
497
|
+
` base exportedAt: ${baseLabel}`,
|
|
498
|
+
` current exportedAt: ${result.current.exportedAt}`,
|
|
499
|
+
""
|
|
500
|
+
];
|
|
501
|
+
if (result.added.nodes.length || result.added.edges.length) {
|
|
502
|
+
blockLines.push("Added:");
|
|
503
|
+
for (const n of result.added.nodes) blockLines.push(` + node ${n.id} (${n.type})`);
|
|
504
|
+
for (const e of result.added.edges)
|
|
505
|
+
blockLines.push(` + edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
|
|
506
|
+
blockLines.push("");
|
|
507
|
+
}
|
|
508
|
+
if (result.removed.nodes.length || result.removed.edges.length) {
|
|
509
|
+
blockLines.push("Removed:");
|
|
510
|
+
for (const n of result.removed.nodes) blockLines.push(` - node ${n.id} (${n.type})`);
|
|
511
|
+
for (const e of result.removed.edges)
|
|
512
|
+
blockLines.push(` - edge ${e.id} \u2014 ${e.source} -> ${e.target} (${e.type}, ${e.provenance})`);
|
|
513
|
+
blockLines.push("");
|
|
514
|
+
}
|
|
515
|
+
if (result.changed.nodes.length || result.changed.edges.length) {
|
|
516
|
+
blockLines.push("Changed:");
|
|
517
|
+
for (const c of result.changed.nodes) {
|
|
518
|
+
blockLines.push(` ~ node ${c.id} \u2014 ${summariseAttrDiff(c.before, c.after)}`);
|
|
519
|
+
}
|
|
520
|
+
for (const c of result.changed.edges) {
|
|
521
|
+
const provBit = c.before.provenance !== c.after.provenance ? `provenance ${c.before.provenance} \u2192 ${c.after.provenance}` : summariseAttrDiff(c.before, c.after);
|
|
522
|
+
blockLines.push(` ~ edge ${c.id} \u2014 ${provBit}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return formatToolResponse({
|
|
526
|
+
summary: `Diff against ${input.againstSnapshot}: ${total} change${total === 1 ? "" : "s"} between the snapshot and the live graph.`,
|
|
527
|
+
block: blockLines.join("\n").trimEnd()
|
|
528
|
+
// Diff results don't have a per-result provenance — the diff spans
|
|
529
|
+
// every edge type and provenance kind. Footer shows n/a.
|
|
530
|
+
});
|
|
531
|
+
} catch (err) {
|
|
532
|
+
if (err instanceof HttpError && err.status === 400) {
|
|
533
|
+
return formatErrorResponse(
|
|
534
|
+
`Could not load snapshot ${input.againstSnapshot}: ${err.message}`
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
return formatErrorResponse(`Error talking to neat-core: ${err.message}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function summariseAttrDiff(before, after) {
|
|
541
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
542
|
+
const changed = [];
|
|
543
|
+
for (const k of keys) {
|
|
544
|
+
if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) changed.push(k);
|
|
545
|
+
}
|
|
546
|
+
return changed.length === 0 ? "attributes differ" : `fields changed: ${changed.sort().join(", ")}`;
|
|
547
|
+
}
|
|
548
|
+
async function getRecentStaleEdges(client2, input) {
|
|
549
|
+
const params = new URLSearchParams();
|
|
550
|
+
if (input.limit !== void 0) params.set("limit", String(input.limit));
|
|
551
|
+
if (input.edgeType) params.set("edgeType", input.edgeType);
|
|
552
|
+
const qs = params.size > 0 ? `?${params.toString()}` : "";
|
|
553
|
+
try {
|
|
554
|
+
const events = await client2.get(
|
|
555
|
+
projectPath(input.project, `/incidents/stale${qs}`)
|
|
556
|
+
);
|
|
557
|
+
if (events.length === 0) {
|
|
558
|
+
return formatEmptyResponse(
|
|
559
|
+
input.edgeType ? `No stale ${input.edgeType} edges recorded.` : "No stale-edge transitions recorded yet."
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
const blockLines = events.map(
|
|
563
|
+
(e) => ` ${e.transitionedAt} \u2014 ${e.source} -[${e.edgeType}]-> ${e.target} (last seen ${e.lastObserved}, threshold ${formatDuration(e.thresholdMs)})`
|
|
564
|
+
);
|
|
565
|
+
return formatToolResponse({
|
|
566
|
+
summary: `${events.length} stale-edge transition${events.length === 1 ? "" : "s"} recorded${input.edgeType ? ` for ${input.edgeType}` : ""}.`,
|
|
567
|
+
block: blockLines.join("\n"),
|
|
568
|
+
// STALE by definition — every event is a transition into STALE.
|
|
569
|
+
provenance: import_types.Provenance.STALE
|
|
570
|
+
});
|
|
571
|
+
} catch (err) {
|
|
572
|
+
return formatErrorResponse(`Error talking to neat-core: ${err.message}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async function checkPolicies(client2, input) {
|
|
576
|
+
try {
|
|
577
|
+
let violations;
|
|
578
|
+
let allowed = true;
|
|
579
|
+
let hypothetical;
|
|
580
|
+
if (input.hypotheticalAction) {
|
|
581
|
+
const body = await postJson(
|
|
582
|
+
client2,
|
|
583
|
+
projectPath(input.project, "/policies/check"),
|
|
584
|
+
{ hypotheticalAction: input.hypotheticalAction }
|
|
585
|
+
);
|
|
586
|
+
violations = body.violations;
|
|
587
|
+
allowed = body.allowed;
|
|
588
|
+
hypothetical = body.hypotheticalAction;
|
|
589
|
+
} else {
|
|
590
|
+
const qsParams = new URLSearchParams();
|
|
591
|
+
if (typeof input.scope === "object" && "policyId" in input.scope) {
|
|
592
|
+
qsParams.set("policyId", input.scope.policyId);
|
|
593
|
+
}
|
|
594
|
+
const qs = qsParams.size > 0 ? `?${qsParams.toString()}` : "";
|
|
595
|
+
violations = await client2.get(
|
|
596
|
+
projectPath(input.project, `/policies/violations${qs}`)
|
|
597
|
+
);
|
|
598
|
+
allowed = violations.every((v) => v.onViolation !== "block");
|
|
599
|
+
}
|
|
600
|
+
if (violations.length === 0) {
|
|
601
|
+
return formatEmptyResponse(
|
|
602
|
+
hypothetical ? `No violations would result from the hypothetical action (${hypothetical.kind}).` : "No policy violations recorded."
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
const blockCount = violations.filter((v) => v.onViolation === "block").length;
|
|
606
|
+
const summaryParts = [];
|
|
607
|
+
if (hypothetical) {
|
|
608
|
+
summaryParts.push(
|
|
609
|
+
`Hypothetical ${hypothetical.kind} would surface ${violations.length} violation${violations.length === 1 ? "" : "s"}`
|
|
610
|
+
);
|
|
611
|
+
} else {
|
|
612
|
+
summaryParts.push(
|
|
613
|
+
`${violations.length} policy violation${violations.length === 1 ? "" : "s"} currently recorded`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
if (blockCount > 0) {
|
|
617
|
+
summaryParts.push(`${blockCount} of which block`);
|
|
618
|
+
}
|
|
619
|
+
if (!allowed && hypothetical) {
|
|
620
|
+
summaryParts.push("action denied");
|
|
621
|
+
}
|
|
622
|
+
const summary = summaryParts.join("; ") + ".";
|
|
623
|
+
const blockLines = violations.map((v) => {
|
|
624
|
+
const subject = v.subject.nodeId ?? v.subject.edgeId ?? v.subject.path?.[0] ?? "(global)";
|
|
625
|
+
return ` \u2022 [${v.severity}/${v.onViolation}] ${v.policyName}: ${v.message} \u2014 ${subject}`;
|
|
626
|
+
});
|
|
627
|
+
const severities = [...new Set(violations.map((v) => v.severity))];
|
|
628
|
+
return formatToolResponse({
|
|
629
|
+
summary,
|
|
630
|
+
block: blockLines.join("\n"),
|
|
631
|
+
// Confidence: hypothetical results inherit a 0.7 cap (the engine
|
|
632
|
+
// can't fully simulate every action shape in MVP); confirmed
|
|
633
|
+
// violations report 1.00 since the engine ran against current state.
|
|
634
|
+
confidence: hypothetical ? 0.7 : 1,
|
|
635
|
+
provenance: severities.join(" ")
|
|
636
|
+
});
|
|
637
|
+
} catch (err) {
|
|
638
|
+
return formatErrorResponse(`Error talking to neat-core: ${err.message}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async function postJson(client2, path, body) {
|
|
642
|
+
const c = client2;
|
|
643
|
+
if (typeof c.post !== "function") {
|
|
644
|
+
throw new Error("HttpClient does not support POST \u2014 required for check_policies dry-run");
|
|
645
|
+
}
|
|
646
|
+
return c.post(path, body);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// src/index.ts
|
|
650
|
+
var baseUrl = process.env.NEAT_CORE_URL ?? "http://localhost:8080";
|
|
651
|
+
var client = createHttpClient(baseUrl);
|
|
652
|
+
var defaultProject = process.env.NEAT_DEFAULT_PROJECT;
|
|
653
|
+
var projectFor = (input) => input.project ?? defaultProject;
|
|
654
|
+
var projectField = import_zod.z.string().optional().describe(
|
|
655
|
+
"Project name when the core hosts more than one (set NEAT_PROJECTS=...). Omit to use the default project."
|
|
656
|
+
);
|
|
657
|
+
var server = new import_mcp2.McpServer({
|
|
658
|
+
name: "neat",
|
|
659
|
+
version: "0.1.0"
|
|
660
|
+
});
|
|
661
|
+
server.tool(
|
|
662
|
+
"get_root_cause",
|
|
663
|
+
"Trace a failing node up its dependency graph to find the underlying cause. Use this when something is breaking and you want to know which upstream component is the actual culprit.",
|
|
664
|
+
{
|
|
665
|
+
errorNode: import_zod.z.string().describe('Graph node id where the error surfaced, e.g. "database:payments-db"'),
|
|
666
|
+
errorId: import_zod.z.string().optional().describe("Specific error event id from incident history; if set, the result is coloured with that error message"),
|
|
667
|
+
project: projectField
|
|
668
|
+
},
|
|
669
|
+
async (input) => getRootCause(client, { ...input, project: projectFor(input) })
|
|
670
|
+
);
|
|
671
|
+
server.tool(
|
|
672
|
+
"get_blast_radius",
|
|
673
|
+
"List every node downstream of the given node \u2014 what would break if this node failed or was redeployed.",
|
|
674
|
+
{
|
|
675
|
+
nodeId: import_zod.z.string().describe("Graph node id to compute blast radius from"),
|
|
676
|
+
depth: import_zod.z.number().int().nonnegative().max(20).optional().describe("Max BFS depth (default 10)"),
|
|
677
|
+
project: projectField
|
|
678
|
+
},
|
|
679
|
+
async (input) => getBlastRadius(client, { ...input, project: projectFor(input) })
|
|
680
|
+
);
|
|
681
|
+
server.tool(
|
|
682
|
+
"get_dependencies",
|
|
683
|
+
"List the transitive outgoing dependencies of a node, BFS to depth N (default 3, max 10). Each result carries distance, edge type, and provenance \u2014 both static (EXTRACTED) and runtime (OBSERVED). Pass depth=1 for direct-only.",
|
|
684
|
+
{
|
|
685
|
+
nodeId: import_zod.z.string().describe("Graph node id to inspect"),
|
|
686
|
+
depth: import_zod.z.number().int().min(1).max(10).optional().describe("BFS depth (default 3, max 10). depth=1 returns direct dependencies only."),
|
|
687
|
+
project: projectField
|
|
688
|
+
},
|
|
689
|
+
async (input) => getDependencies(client, { ...input, project: projectFor(input) })
|
|
690
|
+
);
|
|
691
|
+
server.tool(
|
|
692
|
+
"get_observed_dependencies",
|
|
693
|
+
"List only the runtime (OBSERVED via OTel) outgoing dependencies of a node. Use this to compare what code SAYS the service depends on vs what production actually does.",
|
|
694
|
+
{
|
|
695
|
+
nodeId: import_zod.z.string().describe("Graph node id to inspect"),
|
|
696
|
+
project: projectField
|
|
697
|
+
},
|
|
698
|
+
async (input) => getObservedDependencies(client, { ...input, project: projectFor(input) })
|
|
699
|
+
);
|
|
700
|
+
server.tool(
|
|
701
|
+
"get_incident_history",
|
|
702
|
+
"Return recent OTel error events recorded against a node, most recent first.",
|
|
703
|
+
{
|
|
704
|
+
nodeId: import_zod.z.string().describe("Graph node id to query"),
|
|
705
|
+
limit: import_zod.z.number().int().positive().max(100).optional().describe("Max events to return (default 20)"),
|
|
706
|
+
project: projectField
|
|
707
|
+
},
|
|
708
|
+
async (input) => getIncidentHistory(client, { ...input, project: projectFor(input) })
|
|
709
|
+
);
|
|
710
|
+
server.tool(
|
|
711
|
+
"semantic_search",
|
|
712
|
+
"Search nodes by natural-language query. Uses embedding vectors when an embedder is available (Ollama nomic-embed-text \u2192 in-process MiniLM \u2192 substring fallback) \u2014 phrase the query the way you would describe what you want.",
|
|
713
|
+
{
|
|
714
|
+
query: import_zod.z.string().describe('Free-text query, e.g. "service handling checkout payments"'),
|
|
715
|
+
project: projectField
|
|
716
|
+
},
|
|
717
|
+
async (input) => semanticSearch(client, { ...input, project: projectFor(input) })
|
|
718
|
+
);
|
|
719
|
+
server.tool(
|
|
720
|
+
"get_graph_diff",
|
|
721
|
+
'Diff a saved graph snapshot against the current live graph. Useful for change reviews and post-incidents \u2014 answers "what changed in the architecture between then and now." Returns added/removed/changed nodes and edges with both snapshot timestamps.',
|
|
722
|
+
{
|
|
723
|
+
againstSnapshot: import_zod.z.string().describe(
|
|
724
|
+
'Path or http(s) URL of the snapshot to diff against (the "before" state). The current graph is the "after".'
|
|
725
|
+
),
|
|
726
|
+
project: projectField
|
|
727
|
+
},
|
|
728
|
+
async (input) => getGraphDiff(client, { ...input, project: projectFor(input) })
|
|
729
|
+
);
|
|
730
|
+
server.tool(
|
|
731
|
+
"get_recent_stale_edges",
|
|
732
|
+
"List the most recent OBSERVED \u2192 STALE edge transitions. Use this to spot integrations that have gone quiet \u2014 a CALLS edge that just went stale typically means an upstream stopped calling, not that the link is healthy.",
|
|
733
|
+
{
|
|
734
|
+
limit: import_zod.z.number().int().positive().max(200).optional().describe("Max events to return (default 50)"),
|
|
735
|
+
edgeType: import_zod.z.string().optional().describe('Filter by edge type \u2014 e.g. "CALLS" or "CONNECTS_TO"'),
|
|
736
|
+
project: projectField
|
|
737
|
+
},
|
|
738
|
+
async (input) => getRecentStaleEdges(client, { ...input, project: projectFor(input) })
|
|
739
|
+
);
|
|
740
|
+
server.tool(
|
|
741
|
+
"check_policies",
|
|
742
|
+
"Inspect or dry-run the project's policy.json. Without hypotheticalAction, returns currently-recorded violations. With hypotheticalAction, returns violations that would result if the action were applied. Architectural assertions in five shapes (structural / compatibility / provenance / ownership / blast-radius).",
|
|
743
|
+
{
|
|
744
|
+
scope: import_types2.CheckPoliciesScopeSchema.optional().describe(
|
|
745
|
+
'Narrow to a subset. Default "all".'
|
|
746
|
+
),
|
|
747
|
+
hypotheticalAction: import_types2.HypotheticalActionSchema.optional().describe(
|
|
748
|
+
"Dry-run mode: simulate the action and return resulting violations. Omit for current state."
|
|
749
|
+
),
|
|
750
|
+
project: projectField
|
|
751
|
+
},
|
|
752
|
+
async (input) => checkPolicies(client, {
|
|
753
|
+
...input,
|
|
754
|
+
project: projectFor(input)
|
|
755
|
+
})
|
|
756
|
+
);
|
|
757
|
+
var incidentsPollMs = process.env.NEAT_RESOURCE_POLL_MS ? Number(process.env.NEAT_RESOURCE_POLL_MS) : void 0;
|
|
758
|
+
var resourceRegistration = registerResources(server, client, {
|
|
759
|
+
...incidentsPollMs !== void 0 ? { incidentsPollMs } : {},
|
|
760
|
+
...defaultProject ? { project: defaultProject } : {}
|
|
761
|
+
});
|
|
762
|
+
async function main() {
|
|
763
|
+
const transport = new import_stdio.StdioServerTransport();
|
|
764
|
+
await server.connect(transport);
|
|
765
|
+
}
|
|
766
|
+
var stopPolling = () => {
|
|
767
|
+
resourceRegistration.stop();
|
|
768
|
+
};
|
|
769
|
+
process.on("SIGTERM", stopPolling);
|
|
770
|
+
process.on("SIGINT", stopPolling);
|
|
771
|
+
main().catch((err) => {
|
|
772
|
+
console.error(err);
|
|
773
|
+
process.exit(1);
|
|
774
|
+
});
|
|
775
|
+
//# sourceMappingURL=index.cjs.map
|