@yarkivaev/scada 2.3.85 → 2.3.87
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 +20 -0
- package/index.js +5 -0
- package/package.json +4 -2
- package/src/application/mcp/analyticsMcp.js +376 -0
- package/src/application/mcp/coverageCursor.js +116 -0
- package/src/application/mcp/mcpCoverage.js +95 -0
- package/src/application/mcp/mcpJournal.js +102 -0
- package/src/application/mcp/mcpMetrics.js +69 -0
- package/src/application/mcp/mcpObserve.js +168 -0
- package/src/application/mcp/mcpQuery.js +80 -0
- package/src/application/mcp/mcpReads.js +221 -0
- package/src/application/mcp/mcpResources.js +106 -0
- package/src/application/mcp/mcpText.js +106 -0
- package/src/application/metricsPlant.js +19 -1
- package/src/application/shopWithTimeline.js +15 -6
- package/src/domain/namespace/namespaceTree.js +182 -0
- package/src/domain/plant/plant.js +2 -1
- package/src/domain/span/coverageSpans.js +229 -0
- package/src/domain/sql/sqlGuard.js +175 -0
- package/src/domain/sql/sqlShape.js +16 -0
- package/src/infrastructure/persistence/pg/alerts.js +37 -3
package/README.md
CHANGED
|
@@ -4,6 +4,25 @@ Generic industrial monitoring domain, state persistence, and HTTP APIs (`@yarkiv
|
|
|
4
4
|
|
|
5
5
|
This package is plant-agnostic: it defines `plant` → `shop` → `machine` hierarchy, timeline and alerts, sensor read ports, STOMP ingest pipelines, and PostgreSQL/ClickHouse adapters. Plant-specific routes, sensors, HMI strings, and tag taxonomies live in downstream plant packages.
|
|
6
6
|
|
|
7
|
+
`analyticsMcp` is a stateless Streamable HTTP MCP server. The plant package supplies a `namespaceTree` (path, kind, silence, source topic) and read ports. Tools address nodes by path. `query_postgres` is registered only when a standby query port is supplied. Each call writes one JSON line to the injected journal. `GET /metrics` exposes call counts and in-flight reads.
|
|
8
|
+
|
|
9
|
+
```javascript
|
|
10
|
+
import { analyticsMcp, namespaceTree } from '@yarkivaev/scada';
|
|
11
|
+
|
|
12
|
+
const mcp = analyticsMcp({
|
|
13
|
+
tree: namespaceTree(nodes),
|
|
14
|
+
tokens: { secret: 'analyst' },
|
|
15
|
+
journal(line) { process.stdout.write(line); },
|
|
16
|
+
ports: {
|
|
17
|
+
measurements, segments, operations, alerts, state, samples, cursor, queryClickhouse
|
|
18
|
+
},
|
|
19
|
+
extend(server) { /* plant tools, resources, prompts */ }
|
|
20
|
+
});
|
|
21
|
+
await mcp.listen(0, '127.0.0.1');
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`ports.queryPostgres` registers `query_postgres`. `extend(server)` may add plant tools. `POST /mcp` requires `Authorization: Bearer`.
|
|
25
|
+
|
|
7
26
|
Repository: `https://github.com/yarkivaev/scada`
|
|
8
27
|
|
|
9
28
|
## Install
|
|
@@ -113,6 +132,7 @@ Main entry (`import { … } from '@yarkivaev/scada'`):
|
|
|
113
132
|
| `stompTimeline`, `stompAlerts`, `userDecisions` | STOMP messaging |
|
|
114
133
|
| `machineOwners`, `ownerTimeline`, `httpTimeline`, `httpOperations` | Owner-routed timeline and operations write (local or edge HTTP) |
|
|
115
134
|
| `mqttMetrics`, `modbusMqtt`, `activityTracking`, `amqpMetricsIngest` | Ingest pipelines |
|
|
135
|
+
| `analyticsMcp`, `namespaceTree`, `coverageSpans`, `coverageCursor`, `sqlGuard` | Analytics MCP, path index, coverage spans, checkpoint-or-sample cursor, read-only SQL guard |
|
|
116
136
|
|
|
117
137
|
Subpath exports:
|
|
118
138
|
|
package/index.js
CHANGED
|
@@ -86,3 +86,8 @@ export { default as siteSync } from './src/application/sync/siteSync.js';
|
|
|
86
86
|
export { default as siteSyncSites } from './src/application/sync/siteSyncSites.js';
|
|
87
87
|
export { default as siteSyncTargets } from './src/application/sync/siteSyncTargets.js';
|
|
88
88
|
export { default as siteSyncBind } from './src/application/sync/siteSyncBind.js';
|
|
89
|
+
export { default as namespaceTree } from './src/domain/namespace/namespaceTree.js';
|
|
90
|
+
export { default as coverageSpans } from './src/domain/span/coverageSpans.js';
|
|
91
|
+
export { default as coverageCursor } from './src/application/mcp/coverageCursor.js';
|
|
92
|
+
export { default as sqlGuard } from './src/domain/sql/sqlGuard.js';
|
|
93
|
+
export { default as analyticsMcp } from './src/application/mcp/analyticsMcp.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/scada",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.87",
|
|
4
4
|
"description": "SCADA domain objects, state persistence, and plant monitoring",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -28,11 +28,13 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@clickhouse/client": "^1.0.0",
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
31
32
|
"@yarkivaev/simple-server": "1.0.2",
|
|
32
33
|
"@yarkivaev/source-to-sink": "1.3.7",
|
|
33
34
|
"amqplib": "^0.10.9",
|
|
34
35
|
"pg": "^8.0.0",
|
|
35
|
-
"stompit": "^1.0.0"
|
|
36
|
+
"stompit": "^1.0.0",
|
|
37
|
+
"zod": "^4.6.7"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
38
40
|
"@eslint/js": "^9.39.2",
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
|
+
import mcpMetrics from './mcpMetrics.js';
|
|
6
|
+
import observe from './mcpObserve.js';
|
|
7
|
+
import registerCoverage from './mcpCoverage.js';
|
|
8
|
+
import registerQueries from './mcpQuery.js';
|
|
9
|
+
import registerReads from './mcpReads.js';
|
|
10
|
+
import registerResources from './mcpResources.js';
|
|
11
|
+
|
|
12
|
+
const load = createRequire(import.meta.url);
|
|
13
|
+
const pkg = load('../../../package.json');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Throws when a required bundle part is absent.
|
|
17
|
+
*
|
|
18
|
+
* @param {boolean} ok - whether the part is present
|
|
19
|
+
* @param {string} message - error context
|
|
20
|
+
* @returns {void}
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* assertPart(true, 'Analytics ports are missing');
|
|
24
|
+
*/
|
|
25
|
+
function assertPart(ok, message) {
|
|
26
|
+
if (ok) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
throw new Error(message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Requires the read ports that named tools always register.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} ports - read ports
|
|
36
|
+
* @returns {void}
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* assertPorts(ports);
|
|
40
|
+
*/
|
|
41
|
+
function assertPorts(ports) {
|
|
42
|
+
const names = [
|
|
43
|
+
'alerts', 'cursor', 'measurements', 'operations',
|
|
44
|
+
'queryClickhouse', 'samples', 'segments', 'state'
|
|
45
|
+
];
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
assertPart(typeof ports[name] === 'function', `Analytics port is missing: ${name}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Requires the namespace, journal, tokens, and ports.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} bundle - analyticsMcp bundle
|
|
55
|
+
* @returns {void}
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* assertBundle(bundle);
|
|
59
|
+
*/
|
|
60
|
+
function assertBundle(bundle) {
|
|
61
|
+
const treeOk = Boolean(bundle && bundle.tree && typeof bundle.tree.resolve === 'function');
|
|
62
|
+
assertPart(treeOk, 'Analytics namespace tree is missing');
|
|
63
|
+
assertPart(Boolean(bundle && typeof bundle.journal === 'function'), 'Analytics journal writer is missing');
|
|
64
|
+
assertPart(Boolean(bundle && bundle.tokens), 'Analytics bearer map is missing');
|
|
65
|
+
assertPart(Boolean(bundle && bundle.ports), 'Analytics ports are missing');
|
|
66
|
+
assertPorts(bundle.ports);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Server name and package version for the MCP handshake.
|
|
71
|
+
*
|
|
72
|
+
* @returns {{name: string, version: string}} implementation
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* serverInfo().name;
|
|
76
|
+
*/
|
|
77
|
+
function serverInfo() {
|
|
78
|
+
return { name: 'analytics', version: pkg.version };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Lets a plant package register tools on the same server.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} server - McpServer
|
|
85
|
+
* @param {object} bundle - analyticsMcp bundle
|
|
86
|
+
* @returns {void}
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* extendServer(server, bundle);
|
|
90
|
+
*/
|
|
91
|
+
function extendServer(server, bundle) {
|
|
92
|
+
if (typeof bundle.extend === 'function') {
|
|
93
|
+
bundle.extend(server);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Builds one stateless server for a single HTTP request.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} bundle - analyticsMcp bundle
|
|
101
|
+
* @param {object} metrics - prometheus counters
|
|
102
|
+
* @param {string} caller - bearer caller name
|
|
103
|
+
* @returns {object} McpServer
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* openServer(bundle, metrics, 'analyst');
|
|
107
|
+
*/
|
|
108
|
+
function openServer(bundle, metrics, caller) {
|
|
109
|
+
const server = new McpServer(serverInfo());
|
|
110
|
+
observe(server, { caller, journal: bundle.journal, metrics });
|
|
111
|
+
registerReads(server, bundle.ports, bundle.tree);
|
|
112
|
+
registerCoverage(server, bundle.ports, bundle.tree);
|
|
113
|
+
registerQueries(server, bundle.ports);
|
|
114
|
+
registerResources(server, bundle.tree);
|
|
115
|
+
extendServer(server, bundle);
|
|
116
|
+
return server;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolves a bearer token to a caller name.
|
|
121
|
+
*
|
|
122
|
+
* @param {import('node:http').IncomingMessage} req - request
|
|
123
|
+
* @param {object} tokens - token to caller name
|
|
124
|
+
* @returns {string} caller name or an empty string
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* callerName(req, { secret: 'analyst' });
|
|
128
|
+
*/
|
|
129
|
+
function callerName(req, tokens) {
|
|
130
|
+
const header = req.headers.authorization;
|
|
131
|
+
if (typeof header !== 'string') {
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
const matched = /^Bearer\s+(?<token>\S+)$/iu.exec(header);
|
|
135
|
+
if (!matched || !Object.hasOwn(tokens, matched.groups.token)) {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
const name = tokens[matched.groups.token];
|
|
139
|
+
if (typeof name === 'string' && name.length > 0) {
|
|
140
|
+
return name;
|
|
141
|
+
}
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Closes a one-request transport after the HTTP response finishes.
|
|
147
|
+
*
|
|
148
|
+
* The SDK writes the JSON body after handleRequest returns, so the transport
|
|
149
|
+
* stays open until the response closes.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} transport - StreamableHTTPServerTransport
|
|
152
|
+
* @param {object} server - McpServer
|
|
153
|
+
* @returns {Function} release
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* const release = releaser(transport, server);
|
|
157
|
+
*/
|
|
158
|
+
function releaser(transport, server) {
|
|
159
|
+
let settled = false;
|
|
160
|
+
return function release() {
|
|
161
|
+
if (settled) {
|
|
162
|
+
return Promise.resolve();
|
|
163
|
+
}
|
|
164
|
+
settled = true;
|
|
165
|
+
return Promise.all([transport.close(), server.close()]).then(() => {
|
|
166
|
+
return undefined;
|
|
167
|
+
}, () => {
|
|
168
|
+
return undefined;
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Handles one MCP POST on a fresh stateless transport.
|
|
175
|
+
*
|
|
176
|
+
* @param {import('node:http').IncomingMessage} req - request
|
|
177
|
+
* @param {import('node:http').ServerResponse} res - response
|
|
178
|
+
* @param {object} bundle - analyticsMcp bundle
|
|
179
|
+
* @param {object} metrics - prometheus counters
|
|
180
|
+
* @param {string} caller - bearer caller name
|
|
181
|
+
* @returns {Promise<void>}
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* dispatch(req, res, bundle, metrics, 'analyst');
|
|
185
|
+
*/
|
|
186
|
+
async function dispatch(req, res, bundle, metrics, caller) {
|
|
187
|
+
const server = openServer(bundle, metrics, caller);
|
|
188
|
+
const transport = new StreamableHTTPServerTransport({
|
|
189
|
+
enableJsonResponse: true,
|
|
190
|
+
sessionIdGenerator: undefined
|
|
191
|
+
});
|
|
192
|
+
const done = releaser(transport, server);
|
|
193
|
+
res.on('close', () => {
|
|
194
|
+
done();
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
await server.connect(transport);
|
|
198
|
+
await transport.handleRequest(req, res);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
await done();
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Authorizes a POST /mcp request and then hands it to the SDK.
|
|
207
|
+
*
|
|
208
|
+
* @param {import('node:http').IncomingMessage} req - request
|
|
209
|
+
* @param {import('node:http').ServerResponse} res - response
|
|
210
|
+
* @param {object} bundle - analyticsMcp bundle
|
|
211
|
+
* @param {object} metrics - prometheus counters
|
|
212
|
+
* @returns {Promise<void>}
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* postMcp(req, res, bundle, metrics);
|
|
216
|
+
*/
|
|
217
|
+
async function postMcp(req, res, bundle, metrics) {
|
|
218
|
+
const name = callerName(req, bundle.tokens);
|
|
219
|
+
if (name.length === 0) {
|
|
220
|
+
res.writeHead(401).end();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await dispatch(req, res, bundle, metrics, name);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Writes the Prometheus scrape body.
|
|
228
|
+
*
|
|
229
|
+
* @param {import('node:http').ServerResponse} res - response
|
|
230
|
+
* @param {object} metrics - prometheus counters
|
|
231
|
+
* @returns {void}
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* writeMetrics(res, metrics);
|
|
235
|
+
*/
|
|
236
|
+
function writeMetrics(res, metrics) {
|
|
237
|
+
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' });
|
|
238
|
+
res.end(metrics.text());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Routes /metrics and /mcp.
|
|
243
|
+
*
|
|
244
|
+
* @param {import('node:http').IncomingMessage} req - request
|
|
245
|
+
* @param {import('node:http').ServerResponse} res - response
|
|
246
|
+
* @param {object} bundle - analyticsMcp bundle
|
|
247
|
+
* @param {object} metrics - prometheus counters
|
|
248
|
+
* @returns {Promise<void>}
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* route(req, res, bundle, metrics);
|
|
252
|
+
*/
|
|
253
|
+
function route(req, res, bundle, metrics) {
|
|
254
|
+
if (req.method === 'GET' && req.url === '/metrics') {
|
|
255
|
+
writeMetrics(res, metrics);
|
|
256
|
+
return Promise.resolve();
|
|
257
|
+
}
|
|
258
|
+
if (req.method === 'POST' && req.url === '/mcp') {
|
|
259
|
+
return postMcp(req, res, bundle, metrics);
|
|
260
|
+
}
|
|
261
|
+
res.writeHead(404).end();
|
|
262
|
+
return Promise.resolve();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Answers a failed request without copying the error text.
|
|
267
|
+
*
|
|
268
|
+
* @param {import('node:http').ServerResponse} res - response
|
|
269
|
+
* @returns {void}
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* fail(res);
|
|
273
|
+
*/
|
|
274
|
+
function fail(res) {
|
|
275
|
+
if (res.headersSent) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
279
|
+
res.end('MCP request failed');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Forwards a request and hides handler failures.
|
|
284
|
+
*
|
|
285
|
+
* @param {import('node:http').IncomingMessage} req - request
|
|
286
|
+
* @param {import('node:http').ServerResponse} res - response
|
|
287
|
+
* @param {object} bundle - analyticsMcp bundle
|
|
288
|
+
* @param {object} metrics - prometheus counters
|
|
289
|
+
* @returns {void}
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* onRequest(req, res, bundle, metrics);
|
|
293
|
+
*/
|
|
294
|
+
function onRequest(req, res, bundle, metrics) {
|
|
295
|
+
route(req, res, bundle, metrics).catch(() => {
|
|
296
|
+
fail(res);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Starts listening and resolves the bound address.
|
|
302
|
+
*
|
|
303
|
+
* @param {import('node:http').Server} server - HTTP server
|
|
304
|
+
* @param {number} port - port, or 0 for an ephemeral port
|
|
305
|
+
* @param {string} host - bind address
|
|
306
|
+
* @returns {Promise<object>} address
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* listen(server, 0, '127.0.0.1');
|
|
310
|
+
*/
|
|
311
|
+
function listen(server, port, host) {
|
|
312
|
+
const chosen = typeof port === 'number' ? port : 0;
|
|
313
|
+
const iface = typeof host === 'string' ? host : '127.0.0.1';
|
|
314
|
+
return new Promise((resolve, reject) => {
|
|
315
|
+
function failBind(err) {
|
|
316
|
+
reject(err);
|
|
317
|
+
}
|
|
318
|
+
server.once('error', failBind);
|
|
319
|
+
server.listen(chosen, iface, () => {
|
|
320
|
+
server.removeListener('error', failBind);
|
|
321
|
+
resolve(server.address());
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Closes the HTTP server.
|
|
328
|
+
*
|
|
329
|
+
* @param {import('node:http').Server} server - HTTP server
|
|
330
|
+
* @returns {Promise<void>}
|
|
331
|
+
*
|
|
332
|
+
* @example
|
|
333
|
+
* shut(server);
|
|
334
|
+
*/
|
|
335
|
+
function shut(server) {
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
server.close((err) => {
|
|
338
|
+
if (err) {
|
|
339
|
+
reject(err);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
resolve();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Stateless Streamable HTTP MCP for plant analytics.
|
|
349
|
+
*
|
|
350
|
+
* The plant package supplies a namespaceTree, read ports, a bearer map, and a
|
|
351
|
+
* journal writer. query_postgres is registered only when ports.queryPostgres
|
|
352
|
+
* is a function. extend(server) may register more tools. POST /mcp speaks MCP.
|
|
353
|
+
* GET /metrics is Prometheus text.
|
|
354
|
+
*
|
|
355
|
+
* @param {object} bundle - tree, tokens, journal, ports, optional extend
|
|
356
|
+
* @returns {object} listen and close
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* const mcp = analyticsMcp({ tree, tokens, journal, ports });
|
|
360
|
+
* await mcp.listen(0, '127.0.0.1');
|
|
361
|
+
*/
|
|
362
|
+
export default function analyticsMcp(bundle) {
|
|
363
|
+
assertBundle(bundle);
|
|
364
|
+
const metrics = mcpMetrics();
|
|
365
|
+
const server = http.createServer((req, res) => {
|
|
366
|
+
onRequest(req, res, bundle, metrics);
|
|
367
|
+
});
|
|
368
|
+
return Object.freeze({
|
|
369
|
+
close() {
|
|
370
|
+
return shut(server);
|
|
371
|
+
},
|
|
372
|
+
listen(port, host) {
|
|
373
|
+
return listen(server, port, host);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const EMPTY = '1970-01-01T00:00:00.000Z';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads epoch milliseconds from a cursor value.
|
|
5
|
+
*
|
|
6
|
+
* @param {Date|number|string|object} value - instant or { at }
|
|
7
|
+
* @returns {number} epoch milliseconds, or NaN
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* instant({ at: '2024-11-02T03:04:05.137Z' });
|
|
11
|
+
*/
|
|
12
|
+
function instant(value) {
|
|
13
|
+
if (value && typeof value === 'object' && !(value instanceof Date) && Object.hasOwn(value, 'at')) {
|
|
14
|
+
return instant(value.at);
|
|
15
|
+
}
|
|
16
|
+
if (value instanceof Date) {
|
|
17
|
+
return value.getTime();
|
|
18
|
+
}
|
|
19
|
+
if (typeof value === 'number') {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
if (value === undefined || value === null || value === '') {
|
|
23
|
+
return Number.NaN;
|
|
24
|
+
}
|
|
25
|
+
return Date.parse(String(value));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Formats a covered instant, treating epoch and gaps as empty.
|
|
30
|
+
*
|
|
31
|
+
* @param {Date|number|string|object} value - instant
|
|
32
|
+
* @returns {string} ISO instant, or the empty cursor
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* covered('2024-11-02T03:04:05.137Z');
|
|
36
|
+
*/
|
|
37
|
+
function covered(value) {
|
|
38
|
+
const at = instant(value);
|
|
39
|
+
if (!Number.isFinite(at) || at <= 0) {
|
|
40
|
+
return EMPTY;
|
|
41
|
+
}
|
|
42
|
+
return new Date(at).toISOString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Latest sample instant, or the empty cursor when none exist.
|
|
47
|
+
*
|
|
48
|
+
* @param {Array} samples - instants or objects with ts
|
|
49
|
+
* @returns {string} ISO instant
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* latest([{ ts: '2024-11-02T03:04:05.137Z' }]);
|
|
53
|
+
*/
|
|
54
|
+
function latest(samples) {
|
|
55
|
+
const rows = Array.isArray(samples) ? samples : [];
|
|
56
|
+
let mark = 0;
|
|
57
|
+
for (const sample of rows) {
|
|
58
|
+
const raw = sample && typeof sample === 'object' && Object.hasOwn(sample, 'ts')
|
|
59
|
+
? sample.ts
|
|
60
|
+
: sample;
|
|
61
|
+
const at = instant(raw);
|
|
62
|
+
if (Number.isFinite(at) && at > mark) {
|
|
63
|
+
mark = at;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return mark > 0 ? new Date(mark).toISOString() : EMPTY;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reads the newest ingest checkpoint for a machine.
|
|
71
|
+
*
|
|
72
|
+
* @param {object|undefined} pool - pg pool
|
|
73
|
+
* @param {string} machine - machine id
|
|
74
|
+
* @returns {Promise<string>} ISO instant, or the empty cursor
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* checkpoint(pool, 'machine1');
|
|
78
|
+
*/
|
|
79
|
+
async function checkpoint(pool, machine) {
|
|
80
|
+
if (!pool) {
|
|
81
|
+
return EMPTY;
|
|
82
|
+
}
|
|
83
|
+
const result = await pool.query(
|
|
84
|
+
'SELECT cursor_at FROM ingest_checkpoints WHERE machine = $1 ORDER BY cursor_at DESC LIMIT 1',
|
|
85
|
+
[machine]
|
|
86
|
+
);
|
|
87
|
+
if (result.rows.length === 0) {
|
|
88
|
+
return EMPTY;
|
|
89
|
+
}
|
|
90
|
+
return covered(result.rows[0].cursor_at);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Coverage cursor: ingest checkpoint, else latest metric instant.
|
|
95
|
+
*
|
|
96
|
+
* Central ingest does not write ingest_checkpoints. When that table is empty,
|
|
97
|
+
* the latest sample is the covered bound so coverageSpans can paint present
|
|
98
|
+
* and absent instead of the whole window unknown.
|
|
99
|
+
*
|
|
100
|
+
* @param {object|undefined} pool - pg pool
|
|
101
|
+
* @param {Function} samples - async (args) => sample list
|
|
102
|
+
* @returns {Function} cursor port ({ machine, path }) => ISO instant
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* coverageCursor(pool, ({ path }) => ports.samples({ path }))({ machine: 'm1' });
|
|
106
|
+
*/
|
|
107
|
+
export default function coverageCursor(pool, samples) {
|
|
108
|
+
return async function bound(args) {
|
|
109
|
+
const machine = args.machine || args.path;
|
|
110
|
+
const mark = await checkpoint(pool, machine);
|
|
111
|
+
if (instant(mark) > 0) {
|
|
112
|
+
return mark;
|
|
113
|
+
}
|
|
114
|
+
return latest(await samples(args));
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import coverageSpans from '../../domain/span/coverageSpans.js';
|
|
3
|
+
import { toolText } from './mcpText.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Unwraps an ingest cursor object to its instant.
|
|
7
|
+
*
|
|
8
|
+
* @param {Date|number|string|object} value - cursor
|
|
9
|
+
* @returns {Date|number|string} instant
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* cursorAt({ at: '2024-11-02T03:04:05.137Z' });
|
|
13
|
+
*/
|
|
14
|
+
function cursorAt(value) {
|
|
15
|
+
if (value && typeof value === 'object' && !(value instanceof Date) && Object.hasOwn(value, 'at')) {
|
|
16
|
+
return value.at;
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Builds capped coverage spans for one path.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} path - namespace path
|
|
25
|
+
* @param {object} args - from and to
|
|
26
|
+
* @param {object} ports - samples and cursor
|
|
27
|
+
* @param {object} tree - namespaceTree
|
|
28
|
+
* @returns {Promise<object>} path spans
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* coverOne('site/metric', { from: 'a', to: 'b' }, ports, tree);
|
|
32
|
+
*/
|
|
33
|
+
async function coverOne(path, args, ports, tree) {
|
|
34
|
+
const node = tree.resolve(path);
|
|
35
|
+
const samples = await ports.samples({ from: args.from, path, to: args.to });
|
|
36
|
+
const cursor = await ports.cursor({ path });
|
|
37
|
+
const spans = coverageSpans(
|
|
38
|
+
samples,
|
|
39
|
+
args.from,
|
|
40
|
+
args.to,
|
|
41
|
+
node.silenceSeconds * 1000,
|
|
42
|
+
cursorAt(cursor)
|
|
43
|
+
);
|
|
44
|
+
return {
|
|
45
|
+
path,
|
|
46
|
+
spans: spans.slice(0, 200),
|
|
47
|
+
truncated: spans.length > 200
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reads present, absent, and unknown spans for each path.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} args - paths, from, to
|
|
55
|
+
* @param {object} ports - samples and cursor
|
|
56
|
+
* @param {object} tree - namespaceTree
|
|
57
|
+
* @returns {Promise<object>} tool result
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* runCoverage({ paths: ['site'], from: 'a', to: 'b' }, ports, tree);
|
|
61
|
+
*/
|
|
62
|
+
async function runCoverage(args, ports, tree) {
|
|
63
|
+
const jobs = args.paths.map((path) => {
|
|
64
|
+
return coverOne(path, args, ports, tree);
|
|
65
|
+
});
|
|
66
|
+
const paths = await Promise.all(jobs);
|
|
67
|
+
const truncated = paths.some((item) => {
|
|
68
|
+
return item.truncated === true;
|
|
69
|
+
});
|
|
70
|
+
return toolText({ paths, truncated });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Registers the coverage tool.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} server - McpServer
|
|
77
|
+
* @param {object} ports - read ports
|
|
78
|
+
* @param {object} tree - namespaceTree
|
|
79
|
+
* @returns {void}
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* registerCoverage(server, ports, tree);
|
|
83
|
+
*/
|
|
84
|
+
export default function registerCoverage(server, ports, tree) {
|
|
85
|
+
server.registerTool('coverage', {
|
|
86
|
+
description: 'Present, absent, and unknown spans for metric paths',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
from: z.string(),
|
|
89
|
+
paths: z.array(z.string()),
|
|
90
|
+
to: z.string()
|
|
91
|
+
}
|
|
92
|
+
}, (args) => {
|
|
93
|
+
return runCoverage(args, ports, tree);
|
|
94
|
+
});
|
|
95
|
+
}
|