@yarkivaev/scada 2.3.84 → 2.3.86

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 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`, `sqlGuard` | Analytics MCP, path index, coverage spans, read-only SQL guard |
116
136
 
117
137
  Subpath exports:
118
138
 
package/index.js CHANGED
@@ -86,3 +86,7 @@ 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 sqlGuard } from './src/domain/sql/sqlGuard.js';
92
+ 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.84",
3
+ "version": "2.3.86",
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,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
+ }
@@ -0,0 +1,102 @@
1
+ import sqlShape from '../../domain/sql/sqlShape.js';
2
+
3
+ /**
4
+ * Caps a journal string.
5
+ *
6
+ * @param {string} text - raw text
7
+ * @returns {string} at most 512 characters
8
+ *
9
+ * @example
10
+ * cap('SELECT 1');
11
+ */
12
+ function cap(text) {
13
+ if (text.length > 512) {
14
+ return text.slice(0, 512);
15
+ }
16
+ return text;
17
+ }
18
+
19
+ /**
20
+ * Reads SQL from a query tool call.
21
+ *
22
+ * @param {string} name - tool or resource name
23
+ * @param {object} [args] - tool arguments
24
+ * @returns {string} SQL or an empty string
25
+ *
26
+ * @example
27
+ * sqlText('query_clickhouse', { sql: 'SELECT 1' });
28
+ */
29
+ function sqlText(name, args) {
30
+ if (name !== 'query_clickhouse' && name !== 'query_postgres') {
31
+ return '';
32
+ }
33
+ if (args && typeof args.sql === 'string') {
34
+ return args.sql;
35
+ }
36
+ return '';
37
+ }
38
+
39
+ /**
40
+ * Builds the common journal fields.
41
+ *
42
+ * @param {object} ctx - caller and writers
43
+ * @param {string} name - tool or resource name
44
+ * @param {string} status - ok or error
45
+ * @param {number} durationMs - elapsed milliseconds
46
+ * @returns {object} journal record
47
+ *
48
+ * @example
49
+ * base({ caller: 'analyst' }, 'state', 'ok', 4);
50
+ */
51
+ function base(ctx, name, status, durationMs) {
52
+ return {
53
+ caller: ctx.caller,
54
+ durationMs,
55
+ name,
56
+ status,
57
+ ts: new Date().toISOString()
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Adds capped SQL text and a collapsed shape for query tools.
63
+ *
64
+ * @param {object} record - journal record
65
+ * @param {string} name - tool or resource name
66
+ * @param {object} [args] - tool arguments
67
+ * @returns {object} record
68
+ *
69
+ * @example
70
+ * withSql({ name: 'state' }, 'state');
71
+ */
72
+ function withSql(record, name, args) {
73
+ const sql = sqlText(name, args);
74
+ if (sql.length === 0) {
75
+ return record;
76
+ }
77
+ return {
78
+ ...record,
79
+ shape: cap(sqlShape(sql)),
80
+ sql: cap(sql)
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Writes one JSON line and counts the call.
86
+ *
87
+ * @param {object} ctx - caller, journal, metrics
88
+ * @param {string} name - tool or resource name
89
+ * @param {string} status - ok or error
90
+ * @param {number} started - epoch milliseconds
91
+ * @param {object} [args] - tool arguments
92
+ * @returns {void}
93
+ *
94
+ * @example
95
+ * note({ caller: 'analyst', journal() {}, metrics: { mark() {} } }, 'state', 'ok', Date.now());
96
+ */
97
+ export default function note(ctx, name, status, started, args) {
98
+ const durationMs = Date.now() - started;
99
+ const record = base(ctx, name, status, durationMs);
100
+ ctx.metrics.mark(name, status);
101
+ ctx.journal(`${JSON.stringify(withSql(record, name, args))}\n`);
102
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Escapes a Prometheus label value.
3
+ *
4
+ * @param {string} value - raw label
5
+ * @returns {string} escaped label
6
+ *
7
+ * @example
8
+ * escapeLabel('query_clickhouse');
9
+ */
10
+ function escapeLabel(value) {
11
+ return String(value).replace(/\\/gu, '\\\\').replace(/"/gu, '\\"').replace(/\n/gu, '\\n');
12
+ }
13
+
14
+ /**
15
+ * Renders counters and gauges as Prometheus text.
16
+ *
17
+ * @param {Map<string, number>} calls - tool and status counts
18
+ * @param {object} inflight - counts by engine
19
+ * @returns {string} exposition
20
+ *
21
+ * @example
22
+ * render(new Map(), { clickhouse: 0, plant: 0, postgres: 0 });
23
+ */
24
+ function render(calls, inflight) {
25
+ const lines = ['# TYPE mcp_calls_total counter'];
26
+ for (const [key, count] of calls) {
27
+ const parts = key.split('\u0000');
28
+ lines.push(`mcp_calls_total{tool="${escapeLabel(parts[0])}",status="${escapeLabel(parts[1])}"} ${count}`);
29
+ }
30
+ lines.push('# TYPE mcp_inflight gauge');
31
+ lines.push(`mcp_inflight{engine="clickhouse"} ${inflight.clickhouse}`);
32
+ lines.push(`mcp_inflight{engine="plant"} ${inflight.plant}`);
33
+ lines.push(`mcp_inflight{engine="postgres"} ${inflight.postgres}`);
34
+ return `${lines.join('\n')}\n`;
35
+ }
36
+
37
+ /**
38
+ * Prometheus text for analytics MCP calls.
39
+ *
40
+ * Labels are tool and status on the counter, and engine on the in-flight gauge.
41
+ * Caller, machine, and SQL are not labels.
42
+ *
43
+ * @returns {object} begin, end, mark, and text
44
+ *
45
+ * @example
46
+ * const metrics = mcpMetrics();
47
+ * metrics.mark('state', 'ok');
48
+ * metrics.text();
49
+ */
50
+ export default function mcpMetrics() {
51
+ const calls = new Map();
52
+ const inflight = { clickhouse: 0, plant: 0, postgres: 0 };
53
+ return Object.freeze({
54
+ begin(engine) {
55
+ inflight[engine] += 1;
56
+ },
57
+ end(engine) {
58
+ inflight[engine] -= 1;
59
+ },
60
+ mark(tool, status) {
61
+ const key = `${tool}\u0000${status}`;
62
+ const count = calls.get(key) || 0;
63
+ calls.set(key, count + 1);
64
+ },
65
+ text() {
66
+ return render(calls, inflight);
67
+ }
68
+ });
69
+ }