mcp-google-multi 6.0.0-alpha.24 → 6.0.0-alpha.26
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/arg-normalize.d.ts +2 -2
- package/dist/arg-normalize.js +7 -3
- package/dist/config-file.d.ts +1 -0
- package/dist/config-file.js +4 -0
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +19 -1
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/http-transport.d.ts +5 -0
- package/dist/http-transport.js +2 -1
- package/dist/index.js +59 -7
- package/dist/metrics-tap.d.ts +3 -0
- package/dist/metrics-tap.js +62 -0
- package/dist/registry.d.ts +5 -1
- package/dist/registry.js +21 -2
- package/dist/tools/google-api.js +6 -0
- package/dist/usage-metrics.d.ts +105 -0
- package/dist/usage-metrics.js +567 -0
- package/package.json +1 -1
package/dist/arg-normalize.d.ts
CHANGED
|
@@ -11,9 +11,9 @@ export declare function normalizeCallArguments(shape: ArgShape, args: Record<str
|
|
|
11
11
|
args: Record<string, unknown>;
|
|
12
12
|
renamed: [string, string][];
|
|
13
13
|
};
|
|
14
|
-
export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void): JSONRPCMessage;
|
|
14
|
+
export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void): JSONRPCMessage;
|
|
15
15
|
/** Wrap a server-side transport so tools/call argument keys are normalized
|
|
16
16
|
* before the SDK validates them. The Protocol assigns `onmessage` during
|
|
17
17
|
* connect(); the interceptor lives in that setter, so the wrapper works
|
|
18
18
|
* identically for stdio and (per-request, stateless) HTTP transports. */
|
|
19
|
-
export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void): Transport;
|
|
19
|
+
export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void): Transport;
|
package/dist/arg-normalize.js
CHANGED
|
@@ -36,7 +36,7 @@ export function normalizeCallArguments(shape, args) {
|
|
|
36
36
|
}
|
|
37
37
|
return { args: out ?? args, renamed };
|
|
38
38
|
}
|
|
39
|
-
export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.write(`${l}\n`)) {
|
|
39
|
+
export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.write(`${l}\n`), onRename) {
|
|
40
40
|
const m = msg;
|
|
41
41
|
if (m.method !== 'tools/call' || typeof m.params?.name !== 'string')
|
|
42
42
|
return msg;
|
|
@@ -51,6 +51,10 @@ export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.writ
|
|
|
51
51
|
return msg;
|
|
52
52
|
// Key names only — argument VALUES never reach the log.
|
|
53
53
|
log(`[args] ${m.params.name}: ${renamed.map(([f, t]) => `${f} -> ${t}`).join(', ')}`);
|
|
54
|
+
try {
|
|
55
|
+
onRename?.(m.params.name, renamed.length);
|
|
56
|
+
}
|
|
57
|
+
catch { /* observers never break dispatch */ }
|
|
54
58
|
return {
|
|
55
59
|
...msg,
|
|
56
60
|
params: { ...m.params, arguments: normalized },
|
|
@@ -60,7 +64,7 @@ export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.writ
|
|
|
60
64
|
* before the SDK validates them. The Protocol assigns `onmessage` during
|
|
61
65
|
* connect(); the interceptor lives in that setter, so the wrapper works
|
|
62
66
|
* identically for stdio and (per-request, stateless) HTTP transports. */
|
|
63
|
-
export function withArgNormalization(transport, shapeFor, log) {
|
|
67
|
+
export function withArgNormalization(transport, shapeFor, log, onRename) {
|
|
64
68
|
const wrapper = {
|
|
65
69
|
start: () => transport.start(),
|
|
66
70
|
send: (message, options) => transport.send(message, options),
|
|
@@ -70,7 +74,7 @@ export function withArgNormalization(transport, shapeFor, log) {
|
|
|
70
74
|
get: () => transport.onmessage,
|
|
71
75
|
set: (handler) => {
|
|
72
76
|
transport.onmessage = handler
|
|
73
|
-
? (message, extra) => handler(normalizeMessage(message, shapeFor, log), extra)
|
|
77
|
+
? (message, extra) => handler(normalizeMessage(message, shapeFor, log, onRename), extra)
|
|
74
78
|
: undefined;
|
|
75
79
|
},
|
|
76
80
|
});
|
package/dist/config-file.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface ConfigFile {
|
|
|
17
17
|
defaultAccount?: string;
|
|
18
18
|
discovery?: 'lazy' | 'curated' | 'eager';
|
|
19
19
|
toolsets?: string;
|
|
20
|
+
usageMetrics?: boolean;
|
|
20
21
|
}
|
|
21
22
|
export declare function failStartup(slug: string, message: string): never;
|
|
22
23
|
export declare class ConfigFileError extends Error {
|
package/dist/config-file.js
CHANGED
|
@@ -36,6 +36,10 @@ const configSchema = z.strictObject({
|
|
|
36
36
|
defaultAccount: z.string().optional(),
|
|
37
37
|
discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
|
|
38
38
|
toolsets: z.string().optional(),
|
|
39
|
+
// Local usage metrics (metrics-feature-spec): absent = off. Downgrade rule:
|
|
40
|
+
// a pre-6.0 build reading a config carrying this key fails E_CONFIG_INVALID
|
|
41
|
+
// (strictObject); remove the key first.
|
|
42
|
+
usageMetrics: z.boolean().optional(),
|
|
39
43
|
});
|
|
40
44
|
export function failStartup(slug, message) {
|
|
41
45
|
process.stderr.write(`${slug}: ${message}\n`);
|
package/dist/doctor.d.ts
CHANGED
|
@@ -53,6 +53,9 @@ export interface HttpProbeResult {
|
|
|
53
53
|
}
|
|
54
54
|
/** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
|
|
55
55
|
export declare function apiEnableLink(api: string): string;
|
|
56
|
+
/** One line, state AND source, so the people being measured can see both
|
|
57
|
+
* here and in `diagnose` (metrics spec section 2). Read-only. */
|
|
58
|
+
export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
|
|
56
59
|
/** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
|
|
57
60
|
export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
|
|
58
61
|
export declare function runDiagnostics(deps?: DiagnosticsDeps): Promise<DiagnosticsReport>;
|
package/dist/doctor.js
CHANGED
|
@@ -5,7 +5,9 @@ import { getAccountSet } from './accounts.js';
|
|
|
5
5
|
import { deriveAccountHealth } from './tools/accounts-tool.js';
|
|
6
6
|
import { peekMasterKeyProvenance, deleteMasterKeyMaterial } from './master-key.js';
|
|
7
7
|
import { hasToken } from './token-store.js';
|
|
8
|
-
import { configDir } from './config-file.js';
|
|
8
|
+
import { configDir, loadConfigFile } from './config-file.js';
|
|
9
|
+
import { envValueSource } from './env-load.js';
|
|
10
|
+
import { describeMetricsDir, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
|
|
9
11
|
import { probeApiEnablement } from './api-probe.js';
|
|
10
12
|
import { resolveHttpConfig, HttpConfigError } from './http-config.js';
|
|
11
13
|
import { parseOwnerEmails } from './http-transport.js';
|
|
@@ -114,8 +116,24 @@ function sectionConfig(deps, set) {
|
|
|
114
116
|
hint = (hint ? `${hint} ` : '') + `Move it: \`mv ${envFile} ${target}\`.`;
|
|
115
117
|
}
|
|
116
118
|
}
|
|
119
|
+
lines.push(usageMetricsStatusLine(deps.env));
|
|
117
120
|
return { id: 2, title: 'Config', verdict, lines, ...(hint ? { hint } : {}), ...(slug ? { slug } : {}) };
|
|
118
121
|
}
|
|
122
|
+
/** One line, state AND source, so the people being measured can see both
|
|
123
|
+
* here and in `diagnose` (metrics spec section 2). Read-only. */
|
|
124
|
+
export function usageMetricsStatusLine(env) {
|
|
125
|
+
const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
|
|
126
|
+
let configValue;
|
|
127
|
+
try {
|
|
128
|
+
configValue = loadConfigFile(undefined, 'throw')?.usageMetrics;
|
|
129
|
+
}
|
|
130
|
+
catch { /* invalid config is section 2's business, not this line's */ }
|
|
131
|
+
const state = resolveUsageMetrics(env, configValue, envSrc?.kind === 'file' ? envSrc.file : undefined);
|
|
132
|
+
if (!state.enabled)
|
|
133
|
+
return `local usage metrics: off ${sourceLabel(state.source)}`;
|
|
134
|
+
const d = describeMetricsDir(env);
|
|
135
|
+
return `local usage metrics: on ${sourceLabel(state.source)} -> ${d.dir} (${d.files} files, ${d.kb} KB)`;
|
|
136
|
+
}
|
|
119
137
|
function sectionKeys(deps, aliases) {
|
|
120
138
|
const provenance = deps.masterKeyProvenance();
|
|
121
139
|
const tokensExist = deps.anyTokensExist(aliases);
|
package/dist/env-load.d.ts
CHANGED
|
@@ -9,3 +9,14 @@ export interface EnvLoadPaths {
|
|
|
9
9
|
configDir?: string;
|
|
10
10
|
}
|
|
11
11
|
export declare function loadEnvFiles(paths?: EnvLoadPaths): EnvLoadResult;
|
|
12
|
+
/**
|
|
13
|
+
* Which layer supplied `key`: the real process env, or the first loaded .env
|
|
14
|
+
* file defining it (autoload never overwrites, so first wins). undefined when
|
|
15
|
+
* the key is unset. Powers self-announcing enablement sources (usage metrics).
|
|
16
|
+
*/
|
|
17
|
+
export declare function envValueSource(key: string): {
|
|
18
|
+
kind: 'process';
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'file';
|
|
21
|
+
file: string;
|
|
22
|
+
} | undefined;
|
package/dist/env-load.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -48,5 +48,27 @@ export function loadEnvFiles(paths = {}) {
|
|
|
48
48
|
}
|
|
49
49
|
for (const [k, v] of boot)
|
|
50
50
|
process.env[k] = v;
|
|
51
|
+
lastLoad = { bootKeys: new Set(boot.keys()), loaded };
|
|
51
52
|
return { loaded, searched: candidates };
|
|
52
53
|
}
|
|
54
|
+
let lastLoad;
|
|
55
|
+
/**
|
|
56
|
+
* Which layer supplied `key`: the real process env, or the first loaded .env
|
|
57
|
+
* file defining it (autoload never overwrites, so first wins). undefined when
|
|
58
|
+
* the key is unset. Powers self-announcing enablement sources (usage metrics).
|
|
59
|
+
*/
|
|
60
|
+
export function envValueSource(key) {
|
|
61
|
+
if (process.env[key] === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (!lastLoad || lastLoad.bootKeys.has(key))
|
|
64
|
+
return { kind: 'process' };
|
|
65
|
+
const re = new RegExp(`^\\s*${key}\\s*=`, 'm');
|
|
66
|
+
for (const p of lastLoad.loaded) {
|
|
67
|
+
try {
|
|
68
|
+
if (re.test(readFileSync(p, 'utf-8')))
|
|
69
|
+
return { kind: 'file', file: p };
|
|
70
|
+
}
|
|
71
|
+
catch { /* file vanished since load: fall through */ }
|
|
72
|
+
}
|
|
73
|
+
return { kind: 'process' };
|
|
74
|
+
}
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
|
2
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import type { HttpConfig } from './http-config.js';
|
|
4
4
|
import { type ArgShape } from './arg-normalize.js';
|
|
5
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
5
6
|
export type AuthOutcome = {
|
|
6
7
|
ok: true;
|
|
7
8
|
} | {
|
|
@@ -31,6 +32,10 @@ export interface HttpHostOptions {
|
|
|
31
32
|
dispatchTimeoutMs?: number;
|
|
32
33
|
/** tools/call argument-key normalization lookup (arg-normalize.ts); absent = off. */
|
|
33
34
|
argShapeFor?: (tool: string) => ArgShape | undefined;
|
|
35
|
+
/** Usage-metrics transport tap (metrics-tap.ts); absent = off. */
|
|
36
|
+
metricsTap?: (t: Transport) => Transport;
|
|
37
|
+
/** Usage-metrics argfix observer, forwarded into arg normalization. */
|
|
38
|
+
onArgRename?: (tool: string, renames: number) => void;
|
|
34
39
|
}
|
|
35
40
|
export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
|
|
36
41
|
/** Front guard: an Origin, if present, must be allowlisted; a Host must be
|
package/dist/http-transport.js
CHANGED
|
@@ -183,7 +183,8 @@ export class HttpTransportHost {
|
|
|
183
183
|
timer = setTimeout(() => resolve('timeout'), deadlineMs);
|
|
184
184
|
timer.unref?.();
|
|
185
185
|
});
|
|
186
|
-
|
|
186
|
+
const tapped = this.opts.metricsTap ? this.opts.metricsTap(transport) : transport;
|
|
187
|
+
await this.opts.server.connect(this.opts.argShapeFor ? withArgNormalization(tapped, this.opts.argShapeFor, this.opts.log, this.opts.onArgRename) : tapped);
|
|
187
188
|
// Reflect the dispatch into a non-rejecting arm: if the deadline wins the
|
|
188
189
|
// race, an orphaned handler settling later must not surface as an unhandled
|
|
189
190
|
// rejection — but a genuine dispatch error still propagates (rethrown below).
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
9
9
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
10
|
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
11
11
|
import { GENERATED_GATES, SERVICES } from './services.js';
|
|
12
|
-
import { ToolRegistry } from './registry.js';
|
|
12
|
+
import { ToolRegistry, resolveDiscoveryMode } from './registry.js';
|
|
13
13
|
import { registerDiscoverTools } from './discover.js';
|
|
14
14
|
import { registerEscapeTools } from './tools/google-api.js';
|
|
15
15
|
import { registerAccountTools } from './tools/accounts-tool.js';
|
|
@@ -21,12 +21,15 @@ import { buildIdentityContext } from './identity.js';
|
|
|
21
21
|
import { registerSetupPrompt } from './setup-prompt.js';
|
|
22
22
|
import { applyNetTuning } from './net-tuning.js';
|
|
23
23
|
import { argNormalizationEnabled, withArgNormalization } from './arg-normalize.js';
|
|
24
|
+
import { envValueSource } from './env-load.js';
|
|
25
|
+
import { loadConfigFile } from './config-file.js';
|
|
26
|
+
import { initUsageMetrics, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
|
|
24
27
|
applyNetTuning();
|
|
25
28
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
29
|
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
27
|
-
function buildRegistry(server, ctx, mode) {
|
|
30
|
+
function buildRegistry(server, ctx, mode, metrics = null) {
|
|
28
31
|
const policy = ctx.policy;
|
|
29
|
-
const registry = new ToolRegistry(server, policy, mode);
|
|
32
|
+
const registry = new ToolRegistry(server, policy, mode, metrics);
|
|
30
33
|
const toolsets = getToolsets();
|
|
31
34
|
if (toolsets !== 'all') {
|
|
32
35
|
const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
|
|
@@ -183,15 +186,56 @@ async function main() {
|
|
|
183
186
|
}
|
|
184
187
|
const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
|
|
185
188
|
const wantHttp = transportIncludesHttp(httpCfg.transport);
|
|
189
|
+
// Local usage metrics: fail-closed resolve, self-announcing source, null
|
|
190
|
+
// when off (no wrapper, no dir, nothing initializes). One instance per
|
|
191
|
+
// transport so `boots` keys stay honest under `both`.
|
|
192
|
+
const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
|
|
193
|
+
const metricsState = resolveUsageMetrics(process.env, loadConfigFile()?.usageMetrics, envSrc?.kind === 'file' ? envSrc.file : undefined);
|
|
194
|
+
if (metricsState.warning)
|
|
195
|
+
process.stderr.write(metricsState.warning);
|
|
196
|
+
const metricsInstances = [];
|
|
197
|
+
const initMetricsFor = (transport, mode) => {
|
|
198
|
+
const m = initUsageMetrics(metricsState, { version: pkg.version, mode, transport });
|
|
199
|
+
if (m) {
|
|
200
|
+
metricsInstances.push(m);
|
|
201
|
+
process.stderr.write(`local usage metrics: on ${sourceLabel(metricsState.source)} -> ${m.statusLine()}\n`);
|
|
202
|
+
}
|
|
203
|
+
return m;
|
|
204
|
+
};
|
|
205
|
+
if (!metricsState.enabled && metricsState.source.kind !== 'default') {
|
|
206
|
+
process.stderr.write(`local usage metrics: off ${sourceLabel(metricsState.source)}\n`);
|
|
207
|
+
}
|
|
208
|
+
const flushMetrics = () => { for (const m of metricsInstances)
|
|
209
|
+
m.shutdown(); };
|
|
210
|
+
process.on('exit', flushMetrics);
|
|
211
|
+
if (metricsState.enabled) {
|
|
212
|
+
for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
213
|
+
process.once(sig, () => {
|
|
214
|
+
flushMetrics();
|
|
215
|
+
// stdio has no other signal handler; preserve terminate-on-signal.
|
|
216
|
+
if (!wantHttp)
|
|
217
|
+
process.exit(sig === 'SIGINT' ? 130 : 143);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
186
221
|
// Build one McpServer + registry per transport at boot (P1 / BV gap #4:
|
|
187
222
|
// never rebuilt per request); `both` runs the two concurrently.
|
|
188
223
|
if (wantStdio) {
|
|
189
224
|
const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
|
|
190
|
-
const
|
|
225
|
+
const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
|
|
226
|
+
const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }), undefined, stdioMetrics);
|
|
191
227
|
registry.installListHandler();
|
|
192
228
|
registerSetupPrompt(server);
|
|
193
|
-
|
|
194
|
-
|
|
229
|
+
// Tap sits under arg normalization at the same wrap site; it only counts
|
|
230
|
+
// outbound JSON-RPC error frames (no handler ran) plus id->tool names.
|
|
231
|
+
let transport = new StdioServerTransport();
|
|
232
|
+
if (stdioMetrics) {
|
|
233
|
+
const { tapUsageMetrics } = await import('./metrics-tap.js');
|
|
234
|
+
transport = tapUsageMetrics(transport, stdioMetrics);
|
|
235
|
+
}
|
|
236
|
+
await server.connect(argNormalizationEnabled()
|
|
237
|
+
? withArgNormalization(transport, (n) => registry.argShape(n), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined)
|
|
238
|
+
: transport);
|
|
195
239
|
}
|
|
196
240
|
if (wantHttp) {
|
|
197
241
|
const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
|
|
@@ -208,7 +252,8 @@ async function main() {
|
|
|
208
252
|
process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
|
|
209
253
|
}
|
|
210
254
|
const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
|
|
211
|
-
const
|
|
255
|
+
const httpMetrics = initMetricsFor('http', 'curated');
|
|
256
|
+
const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated', httpMetrics);
|
|
212
257
|
registry.installListHandler();
|
|
213
258
|
registerSetupPrompt(httpServer);
|
|
214
259
|
// B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
|
|
@@ -274,6 +319,11 @@ async function main() {
|
|
|
274
319
|
// re-auth link into the AS's alias_reauth flow instead of a stdio CLI hint.
|
|
275
320
|
const { setHttpReauthBase } = await import('./reauth-hint.js');
|
|
276
321
|
setHttpReauthBase(httpCfg.publicUrl);
|
|
322
|
+
let httpTap;
|
|
323
|
+
if (httpMetrics) {
|
|
324
|
+
const { tapUsageMetrics } = await import('./metrics-tap.js');
|
|
325
|
+
httpTap = (t) => tapUsageMetrics(t, httpMetrics);
|
|
326
|
+
}
|
|
277
327
|
const host = new HttpTransportHost({
|
|
278
328
|
server: httpServer,
|
|
279
329
|
config: httpCfg,
|
|
@@ -283,6 +333,8 @@ async function main() {
|
|
|
283
333
|
routes: authServer.routes,
|
|
284
334
|
log: (l) => process.stderr.write(`[http] ${l}\n`),
|
|
285
335
|
argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
|
|
336
|
+
metricsTap: httpTap,
|
|
337
|
+
onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
|
|
286
338
|
});
|
|
287
339
|
await host.start();
|
|
288
340
|
process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const PENDING_CAP = 1_000;
|
|
2
|
+
export function tapUsageMetrics(transport, metrics) {
|
|
3
|
+
const pending = new Map();
|
|
4
|
+
const wrapper = {
|
|
5
|
+
start: () => transport.start(),
|
|
6
|
+
send: (message, options) => {
|
|
7
|
+
try {
|
|
8
|
+
const m = message;
|
|
9
|
+
if (m.id !== undefined) {
|
|
10
|
+
const tool = pending.get(m.id);
|
|
11
|
+
pending.delete(m.id);
|
|
12
|
+
if (m.error && typeof m.error.code === 'number') {
|
|
13
|
+
if (m.error.code === -32602) {
|
|
14
|
+
if (/unknown tool|not found/i.test(m.error.message ?? ''))
|
|
15
|
+
metrics.recordRpc('tool_not_found');
|
|
16
|
+
else
|
|
17
|
+
metrics.recordRpc('schema_validation', tool);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
metrics.recordRpc(m.error.code);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch { /* the tap may never break the wire */ }
|
|
26
|
+
return transport.send(message, options);
|
|
27
|
+
},
|
|
28
|
+
close: () => transport.close(),
|
|
29
|
+
};
|
|
30
|
+
Object.defineProperty(wrapper, 'onmessage', {
|
|
31
|
+
get: () => transport.onmessage,
|
|
32
|
+
set: (handler) => {
|
|
33
|
+
transport.onmessage = handler
|
|
34
|
+
? (message, extra) => {
|
|
35
|
+
try {
|
|
36
|
+
const m = message;
|
|
37
|
+
if (m.id !== undefined && m.method === 'tools/call' && typeof m.params?.name === 'string') {
|
|
38
|
+
if (pending.size >= PENDING_CAP)
|
|
39
|
+
pending.clear();
|
|
40
|
+
pending.set(m.id, m.params.name);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch { /* never break the wire */ }
|
|
44
|
+
handler(message, extra);
|
|
45
|
+
}
|
|
46
|
+
: undefined;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
for (const prop of ['onclose', 'onerror']) {
|
|
50
|
+
Object.defineProperty(wrapper, prop, {
|
|
51
|
+
get: () => transport[prop],
|
|
52
|
+
set: (v) => {
|
|
53
|
+
transport[prop] = v;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
Object.defineProperty(wrapper, 'sessionId', { get: () => transport.sessionId });
|
|
58
|
+
if (transport.setProtocolVersion) {
|
|
59
|
+
wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
|
|
60
|
+
}
|
|
61
|
+
return wrapper;
|
|
62
|
+
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { type Policy } from './write-control.js';
|
|
4
4
|
import type { ArgShape } from './arg-normalize.js';
|
|
5
|
+
import type { Metrics } from './usage-metrics.js';
|
|
5
6
|
export type Cud = 'read' | 'create' | 'update' | 'delete';
|
|
6
7
|
export type DiscoveryMode = 'lazy' | 'curated' | 'eager';
|
|
7
8
|
export declare function resolveDiscoveryMode(env?: NodeJS.ProcessEnv): DiscoveryMode;
|
|
@@ -34,6 +35,7 @@ export interface CatalogOperation {
|
|
|
34
35
|
export declare function inferCud(name: string): Cud;
|
|
35
36
|
export declare class ToolRegistry {
|
|
36
37
|
private readonly server;
|
|
38
|
+
private readonly metrics;
|
|
37
39
|
readonly tools: ToolEntry[];
|
|
38
40
|
readonly policy: Policy;
|
|
39
41
|
readonly registerTool: McpServer['registerTool'];
|
|
@@ -47,13 +49,15 @@ export declare class ToolRegistry {
|
|
|
47
49
|
/** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
|
|
48
50
|
* lazy surface to curated without touching the configured mode. */
|
|
49
51
|
private expanded;
|
|
50
|
-
constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode);
|
|
52
|
+
constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null);
|
|
51
53
|
registerMeta: McpServer['registerTool'];
|
|
52
54
|
services(): string[];
|
|
53
55
|
/** Declared input-schema keys + scalar kinds for one tool (tools/call arg
|
|
54
56
|
* normalization; the kind drives value coercion on renamed keys). */
|
|
55
57
|
argShape(name: string): ArgShape | undefined;
|
|
56
58
|
catalog(service: string, query?: string): CatalogOperation[];
|
|
59
|
+
/** The metrics recorder, for hook sites (escape hatch); null when off. */
|
|
60
|
+
get usageMetrics(): Metrics | null;
|
|
57
61
|
/** Op-name vocabulary for a service, split by provenance so the capped
|
|
58
62
|
* discover descriptions can list curated ops and only summarize the
|
|
59
63
|
* generated long tail. */
|
package/dist/registry.js
CHANGED
|
@@ -81,6 +81,7 @@ export function inferCud(name) {
|
|
|
81
81
|
}
|
|
82
82
|
export class ToolRegistry {
|
|
83
83
|
server;
|
|
84
|
+
metrics;
|
|
84
85
|
tools = [];
|
|
85
86
|
policy;
|
|
86
87
|
registerTool;
|
|
@@ -94,8 +95,9 @@ export class ToolRegistry {
|
|
|
94
95
|
/** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
|
|
95
96
|
* lazy surface to curated without touching the configured mode. */
|
|
96
97
|
expanded = false;
|
|
97
|
-
constructor(server, policy, mode = resolveDiscoveryMode()) {
|
|
98
|
+
constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null) {
|
|
98
99
|
this.server = server;
|
|
100
|
+
this.metrics = metrics;
|
|
99
101
|
this.policy = policy;
|
|
100
102
|
this.mode = mode;
|
|
101
103
|
this.registerTool = ((name, config, handler) => {
|
|
@@ -194,8 +196,21 @@ export class ToolRegistry {
|
|
|
194
196
|
const finalHandler = this.compactOutput
|
|
195
197
|
? async (...args) => compactResult(await withDefault(...args))
|
|
196
198
|
: withDefault;
|
|
199
|
+
// Usage metrics wrap OUTERMOST and only when enabled: off means no
|
|
200
|
+
// wrapper exists and the chain is byte-identical to the pre-metrics
|
|
201
|
+
// chain. Fan-out width is derived here (bucketed in the module) so the
|
|
202
|
+
// metrics module never imports fanout.
|
|
203
|
+
const instrumented = this.metrics
|
|
204
|
+
? this.metrics.wrap({ name, service, meta: this.registeringMeta, generated: config.cud !== undefined }, finalHandler, (a) => {
|
|
205
|
+
const v = a?.account;
|
|
206
|
+
if (typeof v !== 'string' || (v !== '*' && !v.includes(',')))
|
|
207
|
+
return 1;
|
|
208
|
+
const sel = parseAccountSelector(v);
|
|
209
|
+
return sel.ok ? sel.aliases.length : 1;
|
|
210
|
+
})
|
|
211
|
+
: finalHandler;
|
|
197
212
|
const { cud: _cud, ...sdkConfig } = config;
|
|
198
|
-
return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations },
|
|
213
|
+
return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations }, instrumented);
|
|
199
214
|
});
|
|
200
215
|
}
|
|
201
216
|
registerMeta = ((name, config, handler) => {
|
|
@@ -237,6 +252,10 @@ export class ToolRegistry {
|
|
|
237
252
|
cud: t.cud,
|
|
238
253
|
}));
|
|
239
254
|
}
|
|
255
|
+
/** The metrics recorder, for hook sites (escape hatch); null when off. */
|
|
256
|
+
get usageMetrics() {
|
|
257
|
+
return this.metrics;
|
|
258
|
+
}
|
|
240
259
|
/** Op-name vocabulary for a service, split by provenance so the capped
|
|
241
260
|
* discover descriptions can list curated ops and only summarize the
|
|
242
261
|
* generated long tail. */
|
package/dist/tools/google-api.js
CHANGED
|
@@ -86,12 +86,15 @@ export function registerEscapeTools(registry, policy, deps = {}) {
|
|
|
86
86
|
if (api) {
|
|
87
87
|
requested = resolveApiAliases(api);
|
|
88
88
|
if (!requested) {
|
|
89
|
+
registry.usageMetrics?.recordSearchApi(null);
|
|
89
90
|
return jsonResult({ error: 'unknown_api', message: `Unknown api "${api}".`, hint: `Known APIs: ${apiList}`, retriable: false }, true);
|
|
90
91
|
}
|
|
91
92
|
const enabled = requested.filter(apiEnabled);
|
|
92
93
|
if (enabled.length === 0)
|
|
93
94
|
return toolsetDisabled(requested[0]);
|
|
94
95
|
requested = enabled;
|
|
96
|
+
// Post-resolution SUPPORTED_APIS keys only (closed vocabulary).
|
|
97
|
+
registry.usageMetrics?.recordSearchApi(requested);
|
|
95
98
|
}
|
|
96
99
|
const apis = requested ?? enabledApis;
|
|
97
100
|
const unavailable = [];
|
|
@@ -172,6 +175,7 @@ export function registerEscapeTools(registry, policy, deps = {}) {
|
|
|
172
175
|
}
|
|
173
176
|
}
|
|
174
177
|
if (!method) {
|
|
178
|
+
registry.usageMetrics?.recordEscapeMethod(null);
|
|
175
179
|
const near = nearestMethodIds(String(methodId), index);
|
|
176
180
|
return jsonResult({
|
|
177
181
|
error: 'unknown_method',
|
|
@@ -182,6 +186,8 @@ export function registerEscapeTools(registry, policy, deps = {}) {
|
|
|
182
186
|
account,
|
|
183
187
|
}, true);
|
|
184
188
|
}
|
|
189
|
+
// The RESOLVED index id only, never the caller's methodId argument.
|
|
190
|
+
registry.usageMetrics?.recordEscapeMethod(method.id);
|
|
185
191
|
const cud = cudFromMethod(method);
|
|
186
192
|
const policyService = serviceForAlias(apiKey);
|
|
187
193
|
const lastSegment = method.id.split('.').pop() ?? method.id;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export declare const METRICS_DIR_NAME = "metrics";
|
|
2
|
+
/** Union of every `error:` slug literal emitted anywhere in src/ (kept honest
|
|
3
|
+
* by a set-equality grep test). Anything outside buckets to `other`. */
|
|
4
|
+
export declare const KNOWN_ERROR_SLUGS: ReadonlySet<string>;
|
|
5
|
+
export declare function stateDir(env?: NodeJS.ProcessEnv): string;
|
|
6
|
+
export declare function latBucket(ms: number): string;
|
|
7
|
+
export declare function charsBucket(n: number): string;
|
|
8
|
+
export declare function fanBucket(width: number): string | undefined;
|
|
9
|
+
export type MetricsSource = {
|
|
10
|
+
kind: 'default';
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'config';
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'process-env';
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'env-file';
|
|
17
|
+
file: string;
|
|
18
|
+
};
|
|
19
|
+
export interface UsageMetricsState {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
source: MetricsSource;
|
|
22
|
+
/** exactly one stderr warning on a malformed non-empty env value (fail-closed) */
|
|
23
|
+
warning?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Fail-closed resolution (mirror of resolveDiscoveryMode's fail-open: here the
|
|
27
|
+
* safe state is off). Env wins over config; empty env value = unset, silent;
|
|
28
|
+
* any other value = one warning, off. `envFile` is the .env path that supplied
|
|
29
|
+
* the variable when it did not come from the real process env (attribution
|
|
30
|
+
* is computed by the caller; this module never reads env files).
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveUsageMetrics(env: NodeJS.ProcessEnv, configValue: boolean | undefined, envFile?: string): UsageMetricsState;
|
|
33
|
+
export declare function sourceLabel(source: MetricsSource): string;
|
|
34
|
+
export interface ToolEntryInfo {
|
|
35
|
+
name: string;
|
|
36
|
+
service: string;
|
|
37
|
+
meta: boolean;
|
|
38
|
+
generated: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface InitOptions {
|
|
41
|
+
dir?: string;
|
|
42
|
+
version: string;
|
|
43
|
+
mode: string;
|
|
44
|
+
transport: string;
|
|
45
|
+
env?: NodeJS.ProcessEnv;
|
|
46
|
+
now?: () => number;
|
|
47
|
+
monotonic?: () => number;
|
|
48
|
+
log?: (line: string) => void;
|
|
49
|
+
}
|
|
50
|
+
export declare class Metrics {
|
|
51
|
+
private readonly dir;
|
|
52
|
+
private readonly aggDir;
|
|
53
|
+
private readonly eventsFile;
|
|
54
|
+
private readonly now;
|
|
55
|
+
private readonly mono;
|
|
56
|
+
private readonly log;
|
|
57
|
+
private readonly bootKey;
|
|
58
|
+
private readonly bootId;
|
|
59
|
+
private seq;
|
|
60
|
+
private deltas;
|
|
61
|
+
private events;
|
|
62
|
+
private lastError;
|
|
63
|
+
private lastTool;
|
|
64
|
+
private pendingMid;
|
|
65
|
+
private timer;
|
|
66
|
+
private dirty;
|
|
67
|
+
private failures;
|
|
68
|
+
private disabled;
|
|
69
|
+
constructor(opts: InitOptions);
|
|
70
|
+
private day;
|
|
71
|
+
private initStorage;
|
|
72
|
+
private pruneAggs;
|
|
73
|
+
private day2ms;
|
|
74
|
+
private pruneEventAge;
|
|
75
|
+
/** Outermost dispatch wrapper; a throw inside recording never affects the call. */
|
|
76
|
+
wrap<A extends unknown[], R>(entry: ToolEntryInfo, handler: (...args: A) => Promise<R> | R, widthOf?: (args: unknown) => number): (...args: A) => Promise<R>;
|
|
77
|
+
private record;
|
|
78
|
+
/** Escape hatch: only the RESOLVED methodId (double-gated) or a miss. */
|
|
79
|
+
recordEscapeMethod(idOrNull: string | null): void;
|
|
80
|
+
/** Search instrument: post-resolution SUPPORTED_APIS keys, or null on failure. */
|
|
81
|
+
recordSearchApi(resolvedApis: string[] | null): void;
|
|
82
|
+
recordArgFix(tool: string, by?: number): void;
|
|
83
|
+
/** Protocol-level failures (no handler ran); wired by the transport tap. */
|
|
84
|
+
recordRpc(kind: 'schema_validation' | 'tool_not_found' | number, tool?: string): void;
|
|
85
|
+
private rolloverIfNeeded;
|
|
86
|
+
/** Merge deltas into the day file under lock; append buffered events. */
|
|
87
|
+
flush(): void;
|
|
88
|
+
private rotateIfNeeded;
|
|
89
|
+
/** Best-effort synchronous flush for shutdown paths. */
|
|
90
|
+
shutdown(): void;
|
|
91
|
+
/** For the doctor/diagnose status line. */
|
|
92
|
+
statusLine(): string;
|
|
93
|
+
}
|
|
94
|
+
/** Read-only description of the metrics dir for doctor/diagnose status lines
|
|
95
|
+
* (never creates anything; safe to call with the feature off). */
|
|
96
|
+
export declare function describeMetricsDir(env?: NodeJS.ProcessEnv): {
|
|
97
|
+
dir: string;
|
|
98
|
+
files: number;
|
|
99
|
+
kb: number;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* null when off: no directory, no file, no timer, nothing initializes; setting
|
|
103
|
+
* USAGE_METRICS_PATH alone never enables anything and never creates anything.
|
|
104
|
+
*/
|
|
105
|
+
export declare function initUsageMetrics(state: UsageMetricsState, opts: InitOptions): Metrics | null;
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
6
|
+
import { withFileLock, atomicWriteFileSync } from './fs-atomic.js';
|
|
7
|
+
// Local usage metrics (metrics-feature-spec.md). Default OFF at every layer;
|
|
8
|
+
// when off, nothing here initializes and the dispatch chain carries no wrapper.
|
|
9
|
+
// Zero network egress is structural: this module's import set is a tested
|
|
10
|
+
// allowlist (fs/path/os/crypto/perf_hooks/fs-atomic), so egress code cannot
|
|
11
|
+
// even be expressed here. Every string persisted is a member of a server-owned
|
|
12
|
+
// closed vocabulary, matches a pinned shape regex, or is other/unknown_*/
|
|
13
|
+
// _overflow; free text is unrepresentable in the file format. No identity
|
|
14
|
+
// dimension of any kind: no aliases, hashes, emails, or exact account counts.
|
|
15
|
+
export const METRICS_DIR_NAME = 'metrics';
|
|
16
|
+
const FLUSH_MS = 60_000;
|
|
17
|
+
const RETRY_WINDOW_MS = 10 * 60_000;
|
|
18
|
+
const BIGRAM_GAP_MS = 5 * 60_000;
|
|
19
|
+
const BIGRAM_CAP = 1_500;
|
|
20
|
+
const ERR_SLUG_CAP = 32;
|
|
21
|
+
const ESCAPE_CAP = 500;
|
|
22
|
+
const EVENTS_ROTATE_BYTES = 4 * 1024 * 1024;
|
|
23
|
+
const RETENTION_DAYS = 180;
|
|
24
|
+
const WRITE_FAILURE_LIMIT = 3;
|
|
25
|
+
const LAT_BOUNDS = [100, 250, 500, 1000, 2500, 5000, 10000, 30000];
|
|
26
|
+
const CHARS_BOUNDS = [64, 256, 1024, 4096, 16384, 65536];
|
|
27
|
+
// methodId backstop shape gate: even a tampered discovery cache cannot turn
|
|
28
|
+
// arbitrary strings into recorded values (spec section 1).
|
|
29
|
+
const METHOD_ID_RE = /^[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)+$/;
|
|
30
|
+
const METHOD_ID_MAX = 128;
|
|
31
|
+
const TOOL_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
32
|
+
/** Union of every `error:` slug literal emitted anywhere in src/ (kept honest
|
|
33
|
+
* by a set-equality grep test). Anything outside buckets to `other`. */
|
|
34
|
+
export const KNOWN_ERROR_SLUGS = new Set([
|
|
35
|
+
'E_CIMD_INVALID', 'E_MCP_TOKEN_INVALID', 'E_NO_DEFAULT_ACCOUNT', 'ambiguous',
|
|
36
|
+
'api_not_enabled', 'auth_required', 'binary', 'binary_unsupported',
|
|
37
|
+
'diagnose_failed', 'discovery_unavailable', 'dispatch_timeout', 'forbidden',
|
|
38
|
+
'insufficient_scope', 'internal', 'invalid_client', 'invalid_client_metadata',
|
|
39
|
+
'invalid_grant', 'invalid_params', 'invalid_query', 'invalid_request',
|
|
40
|
+
'invalid_scope', 'network_error', 'not_found', 'rate_limited',
|
|
41
|
+
'reauth_required', 'too_large', 'toolset_disabled', 'unknown_api',
|
|
42
|
+
'unknown_method', 'unsupported_grant_type', 'unsupported_type',
|
|
43
|
+
'untrusted_host', 'upstream_error', 'validation_error', 'write_disabled',
|
|
44
|
+
]);
|
|
45
|
+
const RPC_CODES = new Set([-32700, -32600, -32601, -32603]);
|
|
46
|
+
export function stateDir(env = process.env) {
|
|
47
|
+
return path.join(env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'mcp-google-multi');
|
|
48
|
+
}
|
|
49
|
+
export function latBucket(ms) {
|
|
50
|
+
for (const b of LAT_BOUNDS)
|
|
51
|
+
if (ms <= b)
|
|
52
|
+
return `le${b}`;
|
|
53
|
+
return 'gt30000';
|
|
54
|
+
}
|
|
55
|
+
export function charsBucket(n) {
|
|
56
|
+
for (const b of CHARS_BOUNDS)
|
|
57
|
+
if (n <= b)
|
|
58
|
+
return `le${b}`;
|
|
59
|
+
return 'gt65536';
|
|
60
|
+
}
|
|
61
|
+
export function fanBucket(width) {
|
|
62
|
+
if (width <= 1)
|
|
63
|
+
return undefined;
|
|
64
|
+
if (width === 2)
|
|
65
|
+
return 'w2';
|
|
66
|
+
if (width <= 4)
|
|
67
|
+
return 'w3to4';
|
|
68
|
+
return 'w5plus';
|
|
69
|
+
}
|
|
70
|
+
const ON = new Set(['on', '1', 'true', 'yes']);
|
|
71
|
+
const OFF = new Set(['off', '0', 'false', 'no']);
|
|
72
|
+
/**
|
|
73
|
+
* Fail-closed resolution (mirror of resolveDiscoveryMode's fail-open: here the
|
|
74
|
+
* safe state is off). Env wins over config; empty env value = unset, silent;
|
|
75
|
+
* any other value = one warning, off. `envFile` is the .env path that supplied
|
|
76
|
+
* the variable when it did not come from the real process env (attribution
|
|
77
|
+
* is computed by the caller; this module never reads env files).
|
|
78
|
+
*/
|
|
79
|
+
export function resolveUsageMetrics(env, configValue, envFile) {
|
|
80
|
+
const raw = env.GOOGLE_USAGE_METRICS;
|
|
81
|
+
if (raw !== undefined && raw.trim() !== '') {
|
|
82
|
+
const v = raw.trim().toLowerCase();
|
|
83
|
+
const source = envFile ? { kind: 'env-file', file: envFile } : { kind: 'process-env' };
|
|
84
|
+
if (ON.has(v))
|
|
85
|
+
return { enabled: true, source };
|
|
86
|
+
if (OFF.has(v))
|
|
87
|
+
return { enabled: false, source };
|
|
88
|
+
return {
|
|
89
|
+
enabled: false,
|
|
90
|
+
source: { kind: 'default' },
|
|
91
|
+
warning: `GOOGLE_USAGE_METRICS="${raw}" is not a valid value (on|1|true|yes / off|0|false|no); local usage metrics stay OFF\n`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (configValue === true)
|
|
95
|
+
return { enabled: true, source: { kind: 'config' } };
|
|
96
|
+
return { enabled: false, source: { kind: 'default' } };
|
|
97
|
+
}
|
|
98
|
+
export function sourceLabel(source) {
|
|
99
|
+
switch (source.kind) {
|
|
100
|
+
case 'config': return '(config)';
|
|
101
|
+
case 'process-env': return '(process env)';
|
|
102
|
+
case 'env-file': return `(env file: ${source.file})`;
|
|
103
|
+
default: return '(default)';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function emptyDay(day) {
|
|
107
|
+
return {
|
|
108
|
+
v: 1, day, boots: {}, node: process.version, calls: 0, tools: {},
|
|
109
|
+
hints: {}, retries: {},
|
|
110
|
+
escape: { methods: {}, _overflow: 0, unknown_method: 0, apis_searched: {}, unknown_api: 0 },
|
|
111
|
+
validation: {}, rpc: {}, bigrams: {},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function addInto(target, key, by = 1) {
|
|
115
|
+
target[key] = (target[key] ?? 0) + by;
|
|
116
|
+
}
|
|
117
|
+
/** Deep-add b into a for the DayAgg shape (numbers add, maps union). */
|
|
118
|
+
function mergeDay(a, b) {
|
|
119
|
+
const num = (x, y) => (x ?? 0) + (y ?? 0);
|
|
120
|
+
const map = (x = {}, y = {}) => {
|
|
121
|
+
const out = { ...x };
|
|
122
|
+
for (const [k, v] of Object.entries(y))
|
|
123
|
+
out[k] = (out[k] ?? 0) + v;
|
|
124
|
+
return out;
|
|
125
|
+
};
|
|
126
|
+
const out = emptyDay(a.day);
|
|
127
|
+
out.node = b.node || a.node;
|
|
128
|
+
out.boots = map(a.boots, b.boots);
|
|
129
|
+
out.calls = num(a.calls, b.calls);
|
|
130
|
+
const toolNames = new Set([...Object.keys(a.tools), ...Object.keys(b.tools)]);
|
|
131
|
+
for (const name of toolNames) {
|
|
132
|
+
const x = a.tools[name];
|
|
133
|
+
const y = b.tools[name];
|
|
134
|
+
const t = { n: num(x?.n, y?.n), err: map(x?.err, y?.err), hint: num(x?.hint, y?.hint), lat: map(x?.lat, y?.lat) };
|
|
135
|
+
if (x?.fanout || y?.fanout)
|
|
136
|
+
t.fanout = map(x?.fanout, y?.fanout);
|
|
137
|
+
const argfix = num(x?.argfix, y?.argfix);
|
|
138
|
+
if (argfix > 0)
|
|
139
|
+
t.argfix = argfix;
|
|
140
|
+
out.tools[name] = t;
|
|
141
|
+
}
|
|
142
|
+
const slugSet = new Set([...Object.keys(a.hints), ...Object.keys(b.hints)]);
|
|
143
|
+
for (const s of slugSet) {
|
|
144
|
+
out.hints[s] = {
|
|
145
|
+
hinted: num(a.hints[s]?.hinted, b.hints[s]?.hinted),
|
|
146
|
+
unhinted: num(a.hints[s]?.unhinted, b.hints[s]?.unhinted),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const retrySet = new Set([...Object.keys(a.retries), ...Object.keys(b.retries)]);
|
|
150
|
+
for (const s of retrySet) {
|
|
151
|
+
out.retries[s] = {
|
|
152
|
+
hintedOk: num(a.retries[s]?.hintedOk, b.retries[s]?.hintedOk),
|
|
153
|
+
hintedFail: num(a.retries[s]?.hintedFail, b.retries[s]?.hintedFail),
|
|
154
|
+
unhintedOk: num(a.retries[s]?.unhintedOk, b.retries[s]?.unhintedOk),
|
|
155
|
+
unhintedFail: num(a.retries[s]?.unhintedFail, b.retries[s]?.unhintedFail),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
out.escape = {
|
|
159
|
+
methods: map(a.escape.methods, b.escape.methods),
|
|
160
|
+
_overflow: num(a.escape._overflow, b.escape._overflow),
|
|
161
|
+
unknown_method: num(a.escape.unknown_method, b.escape.unknown_method),
|
|
162
|
+
apis_searched: map(a.escape.apis_searched, b.escape.apis_searched),
|
|
163
|
+
unknown_api: num(a.escape.unknown_api, b.escape.unknown_api),
|
|
164
|
+
};
|
|
165
|
+
out.validation = map(a.validation, b.validation);
|
|
166
|
+
out.rpc = map(a.rpc, b.rpc);
|
|
167
|
+
out.bigrams = map(a.bigrams, b.bigrams);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/** Cap open maps at write time (the file may never exceed the caps). */
|
|
171
|
+
function applyCaps(day) {
|
|
172
|
+
const cap = (m, limit, overflowInto) => {
|
|
173
|
+
const entries = Object.entries(m);
|
|
174
|
+
if (entries.length <= limit)
|
|
175
|
+
return m;
|
|
176
|
+
entries.sort((x, y) => y[1] - x[1]);
|
|
177
|
+
const kept = Object.fromEntries(entries.slice(0, limit));
|
|
178
|
+
const dropped = entries.slice(limit).reduce((s, [, v]) => s + v, 0);
|
|
179
|
+
if (overflowInto)
|
|
180
|
+
overflowInto(dropped);
|
|
181
|
+
else
|
|
182
|
+
kept.other = (kept.other ?? 0) + dropped;
|
|
183
|
+
return kept;
|
|
184
|
+
};
|
|
185
|
+
for (const t of Object.values(day.tools))
|
|
186
|
+
t.err = cap(t.err, ERR_SLUG_CAP);
|
|
187
|
+
day.escape.methods = cap(day.escape.methods, ESCAPE_CAP, (n) => { day.escape._overflow += n; });
|
|
188
|
+
day.bigrams = cap(day.bigrams, BIGRAM_CAP, (n) => { day.bigrams._overflow = (day.bigrams._overflow ?? 0) + n; });
|
|
189
|
+
return day;
|
|
190
|
+
}
|
|
191
|
+
export class Metrics {
|
|
192
|
+
dir;
|
|
193
|
+
aggDir;
|
|
194
|
+
eventsFile;
|
|
195
|
+
now;
|
|
196
|
+
mono;
|
|
197
|
+
log;
|
|
198
|
+
bootKey;
|
|
199
|
+
bootId = randomBytes(3).toString('hex');
|
|
200
|
+
seq = 0;
|
|
201
|
+
deltas;
|
|
202
|
+
events = [];
|
|
203
|
+
lastError = new Map();
|
|
204
|
+
lastTool;
|
|
205
|
+
pendingMid;
|
|
206
|
+
timer;
|
|
207
|
+
dirty = false;
|
|
208
|
+
failures = 0;
|
|
209
|
+
disabled = false;
|
|
210
|
+
constructor(opts) {
|
|
211
|
+
this.dir = opts.dir ?? path.join(stateDir(opts.env), METRICS_DIR_NAME);
|
|
212
|
+
this.aggDir = path.join(this.dir, 'agg');
|
|
213
|
+
this.eventsFile = path.join(this.dir, 'events.jsonl');
|
|
214
|
+
this.now = opts.now ?? Date.now;
|
|
215
|
+
this.mono = opts.monotonic ?? (() => performance.now());
|
|
216
|
+
this.log = opts.log ?? ((l) => process.stderr.write(l));
|
|
217
|
+
this.bootKey = `${opts.version}/${opts.mode}/${opts.transport}`;
|
|
218
|
+
this.deltas = emptyDay(this.day());
|
|
219
|
+
this.deltas.boots[this.bootKey] = 1;
|
|
220
|
+
this.dirty = true;
|
|
221
|
+
this.initStorage();
|
|
222
|
+
this.timer = setInterval(() => this.flush(), FLUSH_MS);
|
|
223
|
+
this.timer.unref?.();
|
|
224
|
+
}
|
|
225
|
+
day() {
|
|
226
|
+
return new Date(this.now()).toISOString().slice(0, 10);
|
|
227
|
+
}
|
|
228
|
+
initStorage() {
|
|
229
|
+
try {
|
|
230
|
+
const existed = fs.existsSync(this.dir);
|
|
231
|
+
fs.mkdirSync(this.aggDir, { recursive: true, mode: 0o700 });
|
|
232
|
+
if (existed) {
|
|
233
|
+
const mode = fs.statSync(this.dir).mode & 0o777;
|
|
234
|
+
if ((mode & 0o077) !== 0) {
|
|
235
|
+
this.log(`local usage metrics: ${this.dir} has mode ${mode.toString(8)} (wider than 0700); not changing it, but the files record activity\n`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
this.pruneAggs();
|
|
239
|
+
this.pruneEventAge(this.eventsFile);
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
this.log(`local usage metrics: init failed (${e.message}); disabled for this process\n`);
|
|
243
|
+
this.disabled = true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
pruneAggs() {
|
|
247
|
+
const cutoff = this.day2ms(this.day()) - RETENTION_DAYS * 86_400_000;
|
|
248
|
+
for (const f of fs.readdirSync(this.aggDir)) {
|
|
249
|
+
const m = f.match(/^(\d{4}-\d{2}-\d{2})\.json$/);
|
|
250
|
+
if (m && this.day2ms(m[1]) < cutoff) {
|
|
251
|
+
try {
|
|
252
|
+
fs.rmSync(path.join(this.aggDir, f));
|
|
253
|
+
}
|
|
254
|
+
catch { /* prune is best-effort */ }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
day2ms(day) {
|
|
259
|
+
return Date.parse(`${day}T00:00:00Z`);
|
|
260
|
+
}
|
|
261
|
+
pruneEventAge(file) {
|
|
262
|
+
if (!fs.existsSync(file))
|
|
263
|
+
return;
|
|
264
|
+
const cutoff = this.now() - RETENTION_DAYS * 86_400_000;
|
|
265
|
+
const lines = fs.readFileSync(file, 'utf-8').split('\n');
|
|
266
|
+
const kept = lines.filter((l) => {
|
|
267
|
+
if (!l.trim())
|
|
268
|
+
return false;
|
|
269
|
+
try {
|
|
270
|
+
const ts = JSON.parse(l).ts;
|
|
271
|
+
return typeof ts === 'string' && Date.parse(ts) >= cutoff;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return false; // torn or corrupt line: drop
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
if (kept.length !== lines.filter((l) => l.trim()).length) {
|
|
278
|
+
atomicWriteFileSync(file, kept.join('\n') + (kept.length ? '\n' : ''));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** Outermost dispatch wrapper; a throw inside recording never affects the call. */
|
|
282
|
+
wrap(entry, handler, widthOf) {
|
|
283
|
+
return async (...args) => {
|
|
284
|
+
const started = this.mono();
|
|
285
|
+
const result = await handler(...args);
|
|
286
|
+
try {
|
|
287
|
+
this.record(entry, args[0], result, this.mono() - started, widthOf);
|
|
288
|
+
}
|
|
289
|
+
catch { /* metrics may never fail a tool call */ }
|
|
290
|
+
return result;
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
record(entry, firstArg, result, ms, widthOf) {
|
|
294
|
+
if (this.disabled)
|
|
295
|
+
return;
|
|
296
|
+
this.rolloverIfNeeded();
|
|
297
|
+
const r = result;
|
|
298
|
+
const ok = !r?.isError;
|
|
299
|
+
let slug;
|
|
300
|
+
let hinted;
|
|
301
|
+
if (!ok) {
|
|
302
|
+
try {
|
|
303
|
+
const first = r?.content?.[0]?.text;
|
|
304
|
+
if (typeof first === 'string' && first.length < 65_536) {
|
|
305
|
+
const env = JSON.parse(first);
|
|
306
|
+
if (typeof env.error === 'string')
|
|
307
|
+
slug = KNOWN_ERROR_SLUGS.has(env.error) ? env.error : 'other';
|
|
308
|
+
hinted = typeof env.hint === 'string' && env.hint.length > 0;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
slug = 'other';
|
|
313
|
+
}
|
|
314
|
+
slug ??= 'other';
|
|
315
|
+
}
|
|
316
|
+
const chars = (r?.content ?? []).reduce((s, c) => s + (typeof c.text === 'string' ? c.text.length : 0), 0);
|
|
317
|
+
let width = 1;
|
|
318
|
+
if (widthOf) {
|
|
319
|
+
try {
|
|
320
|
+
width = widthOf(firstArg);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
width = 1;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const fan = fanBucket(width);
|
|
327
|
+
const t = (this.deltas.tools[entry.name] ??= { n: 0, err: {}, hint: 0, lat: {} });
|
|
328
|
+
t.n += 1;
|
|
329
|
+
this.deltas.calls += 1;
|
|
330
|
+
addInto(t.lat, latBucket(ms));
|
|
331
|
+
if (slug) {
|
|
332
|
+
addInto(t.err, slug);
|
|
333
|
+
if (hinted)
|
|
334
|
+
t.hint += 1;
|
|
335
|
+
const h = (this.deltas.hints[slug] ??= { hinted: 0, unhinted: 0 });
|
|
336
|
+
if (hinted)
|
|
337
|
+
h.hinted += 1;
|
|
338
|
+
else
|
|
339
|
+
h.unhinted += 1;
|
|
340
|
+
}
|
|
341
|
+
if (fan) {
|
|
342
|
+
const f = (t.fanout ??= { calls: 0 });
|
|
343
|
+
f.calls += 1;
|
|
344
|
+
addInto(f, fan);
|
|
345
|
+
}
|
|
346
|
+
// retry self-correction chain (spec section 1): in-memory only.
|
|
347
|
+
const nowM = this.mono();
|
|
348
|
+
const prev = this.lastError.get(entry.name);
|
|
349
|
+
if (prev && nowM - prev.at <= RETRY_WINDOW_MS) {
|
|
350
|
+
const rr = (this.deltas.retries[prev.slug] ??= { hintedOk: 0, hintedFail: 0, unhintedOk: 0, unhintedFail: 0 });
|
|
351
|
+
const key = `${prev.hinted ? 'hinted' : 'unhinted'}${ok ? 'Ok' : 'Fail'}`;
|
|
352
|
+
rr[key] += 1;
|
|
353
|
+
this.lastError.delete(entry.name);
|
|
354
|
+
}
|
|
355
|
+
if (!ok && slug)
|
|
356
|
+
this.lastError.set(entry.name, { slug, hinted: hinted === true, at: nowM });
|
|
357
|
+
// bigrams: ordered pairs within the process, 5-minute gap breaks the chain.
|
|
358
|
+
if (this.lastTool && nowM - this.lastTool.at <= BIGRAM_GAP_MS) {
|
|
359
|
+
addInto(this.deltas.bigrams, `${this.lastTool.name}>${entry.name}`);
|
|
360
|
+
}
|
|
361
|
+
this.lastTool = { name: entry.name, at: nowM };
|
|
362
|
+
const event = {
|
|
363
|
+
ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
|
|
364
|
+
boot: this.bootId,
|
|
365
|
+
seq: this.seq++,
|
|
366
|
+
tool: entry.name,
|
|
367
|
+
ok,
|
|
368
|
+
ms: Math.round(ms),
|
|
369
|
+
chars: charsBucket(chars),
|
|
370
|
+
src: 'handler',
|
|
371
|
+
};
|
|
372
|
+
if (slug)
|
|
373
|
+
event.err = slug;
|
|
374
|
+
if (hinted !== undefined)
|
|
375
|
+
event.hint = hinted;
|
|
376
|
+
if (fan)
|
|
377
|
+
event.fan = fan;
|
|
378
|
+
// mid: set mid-dispatch by recordEscapeMethod, attached to the escape
|
|
379
|
+
// call's own event line (per-event field, never a synthetic event).
|
|
380
|
+
if (entry.name === 'google_api_call' && this.pendingMid) {
|
|
381
|
+
event.mid = this.pendingMid;
|
|
382
|
+
this.pendingMid = undefined;
|
|
383
|
+
}
|
|
384
|
+
this.events.push(JSON.stringify(event));
|
|
385
|
+
this.dirty = true;
|
|
386
|
+
}
|
|
387
|
+
/** Escape hatch: only the RESOLVED methodId (double-gated) or a miss. */
|
|
388
|
+
recordEscapeMethod(idOrNull) {
|
|
389
|
+
try {
|
|
390
|
+
this.rolloverIfNeeded();
|
|
391
|
+
if (idOrNull === null) {
|
|
392
|
+
this.deltas.escape.unknown_method += 1;
|
|
393
|
+
}
|
|
394
|
+
else if (idOrNull.length <= METHOD_ID_MAX && METHOD_ID_RE.test(idOrNull)) {
|
|
395
|
+
addInto(this.deltas.escape.methods, idOrNull);
|
|
396
|
+
this.pendingMid = idOrNull;
|
|
397
|
+
}
|
|
398
|
+
this.dirty = true;
|
|
399
|
+
}
|
|
400
|
+
catch { /* never throws outward */ }
|
|
401
|
+
}
|
|
402
|
+
/** Search instrument: post-resolution SUPPORTED_APIS keys, or null on failure. */
|
|
403
|
+
recordSearchApi(resolvedApis) {
|
|
404
|
+
try {
|
|
405
|
+
this.rolloverIfNeeded();
|
|
406
|
+
if (resolvedApis === null)
|
|
407
|
+
this.deltas.escape.unknown_api += 1;
|
|
408
|
+
else
|
|
409
|
+
for (const k of resolvedApis)
|
|
410
|
+
addInto(this.deltas.escape.apis_searched, k);
|
|
411
|
+
this.dirty = true;
|
|
412
|
+
}
|
|
413
|
+
catch { /* never throws outward */ }
|
|
414
|
+
}
|
|
415
|
+
recordArgFix(tool, by = 1) {
|
|
416
|
+
try {
|
|
417
|
+
this.rolloverIfNeeded();
|
|
418
|
+
if (!TOOL_NAME_RE.test(tool))
|
|
419
|
+
return;
|
|
420
|
+
const t = (this.deltas.tools[tool] ??= { n: 0, err: {}, hint: 0, lat: {} });
|
|
421
|
+
t.argfix = (t.argfix ?? 0) + by;
|
|
422
|
+
this.dirty = true;
|
|
423
|
+
}
|
|
424
|
+
catch { /* never throws outward */ }
|
|
425
|
+
}
|
|
426
|
+
/** Protocol-level failures (no handler ran); wired by the transport tap. */
|
|
427
|
+
recordRpc(kind, tool) {
|
|
428
|
+
try {
|
|
429
|
+
this.rolloverIfNeeded();
|
|
430
|
+
if (kind === 'schema_validation') {
|
|
431
|
+
// The tap's name came off the wire; the shape gate keeps a weird
|
|
432
|
+
// -32602 from writing free text (registered names always pass).
|
|
433
|
+
if (tool && TOOL_NAME_RE.test(tool))
|
|
434
|
+
addInto(this.deltas.validation, tool);
|
|
435
|
+
}
|
|
436
|
+
else if (kind === 'tool_not_found') {
|
|
437
|
+
addInto(this.deltas.rpc, 'tool_not_found');
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
addInto(this.deltas.rpc, RPC_CODES.has(kind) ? `rpc_error_${kind}` : 'rpc_error_other');
|
|
441
|
+
}
|
|
442
|
+
this.events.push(JSON.stringify({
|
|
443
|
+
ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
|
|
444
|
+
boot: this.bootId, seq: this.seq++,
|
|
445
|
+
tool: tool && TOOL_NAME_RE.test(tool) ? tool : 'other',
|
|
446
|
+
ok: false, ms: 0, chars: 'le64', src: 'rpc',
|
|
447
|
+
}));
|
|
448
|
+
this.dirty = true;
|
|
449
|
+
}
|
|
450
|
+
catch { /* never throws outward */ }
|
|
451
|
+
}
|
|
452
|
+
rolloverIfNeeded() {
|
|
453
|
+
const today = this.day();
|
|
454
|
+
if (this.deltas.day !== today) {
|
|
455
|
+
this.flush();
|
|
456
|
+
this.deltas = emptyDay(today);
|
|
457
|
+
this.pruneAggs();
|
|
458
|
+
this.dirty = false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/** Merge deltas into the day file under lock; append buffered events. */
|
|
462
|
+
flush() {
|
|
463
|
+
if (!this.dirty || this.disabled)
|
|
464
|
+
return;
|
|
465
|
+
const deltas = this.deltas;
|
|
466
|
+
const events = this.events;
|
|
467
|
+
try {
|
|
468
|
+
const file = path.join(this.aggDir, `${deltas.day}.json`);
|
|
469
|
+
withFileLock(file, () => {
|
|
470
|
+
let current = emptyDay(deltas.day);
|
|
471
|
+
try {
|
|
472
|
+
const onDisk = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
473
|
+
if (onDisk && onDisk.v === 1 && onDisk.day === deltas.day)
|
|
474
|
+
current = onDisk;
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
if (fs.existsSync(file))
|
|
478
|
+
this.log(`local usage metrics: unreadable day file ${file}; starting fresh\n`);
|
|
479
|
+
}
|
|
480
|
+
atomicWriteFileSync(file, JSON.stringify(applyCaps(mergeDay(current, deltas))));
|
|
481
|
+
});
|
|
482
|
+
if (events.length > 0) {
|
|
483
|
+
fs.appendFileSync(this.eventsFile, events.join('\n') + '\n', { mode: 0o600 });
|
|
484
|
+
this.rotateIfNeeded();
|
|
485
|
+
}
|
|
486
|
+
this.deltas = emptyDay(deltas.day);
|
|
487
|
+
this.events = [];
|
|
488
|
+
this.dirty = false;
|
|
489
|
+
this.failures = 0;
|
|
490
|
+
}
|
|
491
|
+
catch (e) {
|
|
492
|
+
this.failures += 1;
|
|
493
|
+
if (this.failures >= WRITE_FAILURE_LIMIT) {
|
|
494
|
+
this.disabled = true;
|
|
495
|
+
if (this.timer)
|
|
496
|
+
clearInterval(this.timer);
|
|
497
|
+
this.log(`local usage metrics: ${WRITE_FAILURE_LIMIT} consecutive write failures (${e.message}); disabled for this process\n`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
rotateIfNeeded() {
|
|
502
|
+
try {
|
|
503
|
+
if (fs.statSync(this.eventsFile).size < EVENTS_ROTATE_BYTES)
|
|
504
|
+
return;
|
|
505
|
+
this.pruneEventAge(this.eventsFile);
|
|
506
|
+
if (fs.statSync(this.eventsFile).size < EVENTS_ROTATE_BYTES)
|
|
507
|
+
return;
|
|
508
|
+
fs.renameSync(this.eventsFile, path.join(this.dir, 'events.1.jsonl'));
|
|
509
|
+
}
|
|
510
|
+
catch { /* rotation is best-effort */ }
|
|
511
|
+
}
|
|
512
|
+
/** Best-effort synchronous flush for shutdown paths. */
|
|
513
|
+
shutdown() {
|
|
514
|
+
if (this.timer)
|
|
515
|
+
clearInterval(this.timer);
|
|
516
|
+
this.flush();
|
|
517
|
+
}
|
|
518
|
+
/** For the doctor/diagnose status line. */
|
|
519
|
+
statusLine() {
|
|
520
|
+
try {
|
|
521
|
+
const files = [
|
|
522
|
+
...fs.readdirSync(this.aggDir).map((f) => path.join(this.aggDir, f)),
|
|
523
|
+
this.eventsFile, path.join(this.dir, 'events.1.jsonl'),
|
|
524
|
+
].filter((f) => fs.existsSync(f));
|
|
525
|
+
const bytes = files.reduce((s, f) => s + fs.statSync(f).size, 0);
|
|
526
|
+
return `${this.dir} (${files.length} files, ${Math.round(bytes / 1024)} KB)`;
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
return this.dir;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/** Read-only description of the metrics dir for doctor/diagnose status lines
|
|
534
|
+
* (never creates anything; safe to call with the feature off). */
|
|
535
|
+
export function describeMetricsDir(env = process.env) {
|
|
536
|
+
const dir = env.USAGE_METRICS_PATH ?? path.join(stateDir(env), METRICS_DIR_NAME);
|
|
537
|
+
let files = 0;
|
|
538
|
+
let bytes = 0;
|
|
539
|
+
try {
|
|
540
|
+
const candidates = [
|
|
541
|
+
...fs.readdirSync(path.join(dir, 'agg')).map((f) => path.join(dir, 'agg', f)),
|
|
542
|
+
path.join(dir, 'events.jsonl'),
|
|
543
|
+
path.join(dir, 'events.1.jsonl'),
|
|
544
|
+
];
|
|
545
|
+
for (const f of candidates) {
|
|
546
|
+
try {
|
|
547
|
+
bytes += fs.statSync(f).size;
|
|
548
|
+
files += 1;
|
|
549
|
+
}
|
|
550
|
+
catch { /* absent */ }
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch { /* dir absent: zeros */ }
|
|
554
|
+
return { dir, files, kb: Math.round(bytes / 1024) };
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* null when off: no directory, no file, no timer, nothing initializes; setting
|
|
558
|
+
* USAGE_METRICS_PATH alone never enables anything and never creates anything.
|
|
559
|
+
*/
|
|
560
|
+
export function initUsageMetrics(state, opts) {
|
|
561
|
+
if (!state.enabled)
|
|
562
|
+
return null;
|
|
563
|
+
return new Metrics({
|
|
564
|
+
...opts,
|
|
565
|
+
dir: opts.dir ?? (opts.env ?? process.env).USAGE_METRICS_PATH ?? undefined,
|
|
566
|
+
});
|
|
567
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
3
|
+
"version": "6.0.0-alpha.26",
|
|
4
4
|
"description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|