mcp-google-multi 6.0.0-alpha.23 → 6.0.0-alpha.25
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/config-file.d.ts +1 -0
- package/dist/config-file.js +4 -0
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/index.js +40 -5
- package/dist/registry.d.ts +3 -1
- package/dist/registry.js +17 -2
- package/dist/tools/_coerce.d.ts +4 -0
- package/dist/tools/_coerce.js +10 -0
- package/dist/tools/drive.js +2 -2
- package/dist/tools/gmail.js +2 -2
- package/dist/usage-metrics.d.ts +98 -0
- package/dist/usage-metrics.js +538 -0
- package/package.json +2 -1
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/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/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,11 +186,43 @@ 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 registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }));
|
|
225
|
+
const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }), undefined, initMetricsFor('stdio', resolveDiscoveryMode()));
|
|
191
226
|
registry.installListHandler();
|
|
192
227
|
registerSetupPrompt(server);
|
|
193
228
|
const stdioTransport = new StdioServerTransport();
|
|
@@ -208,7 +243,7 @@ async function main() {
|
|
|
208
243
|
process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
|
|
209
244
|
}
|
|
210
245
|
const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
|
|
211
|
-
const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated');
|
|
246
|
+
const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated', initMetricsFor('http', 'curated'));
|
|
212
247
|
registry.installListHandler();
|
|
213
248
|
registerSetupPrompt(httpServer);
|
|
214
249
|
// B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
|
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,7 +49,7 @@ 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
|
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) => {
|
package/dist/tools/_coerce.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<T>>;
|
|
3
3
|
export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
|
|
4
|
+
/** Numeric args arrive string-encoded from some clients; over stdio the SDK
|
|
5
|
+
* validates the schema directly (the HTTP transport's arg-normalization layer
|
|
6
|
+
* never runs), so the coercion must live in the schema itself. */
|
|
7
|
+
export declare function coerceNumber<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
|
|
4
8
|
export declare const coerceBoolean: z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodBoolean>;
|
package/dist/tools/_coerce.js
CHANGED
|
@@ -22,6 +22,16 @@ export function coerceArray(element) {
|
|
|
22
22
|
export function coerceJson(schema) {
|
|
23
23
|
return z.preprocess((val) => (typeof val === 'string' ? parseJsonLoose(val) : val), schema);
|
|
24
24
|
}
|
|
25
|
+
/** Numeric args arrive string-encoded from some clients; over stdio the SDK
|
|
26
|
+
* validates the schema directly (the HTTP transport's arg-normalization layer
|
|
27
|
+
* never runs), so the coercion must live in the schema itself. */
|
|
28
|
+
export function coerceNumber(schema) {
|
|
29
|
+
return z.preprocess((val) => {
|
|
30
|
+
if (typeof val === 'string' && val.trim() !== '' && !Number.isNaN(Number(val)))
|
|
31
|
+
return Number(val);
|
|
32
|
+
return val;
|
|
33
|
+
}, schema);
|
|
34
|
+
}
|
|
25
35
|
export const coerceBoolean = z.preprocess((val) => {
|
|
26
36
|
if (typeof val === 'boolean')
|
|
27
37
|
return val;
|
package/dist/tools/drive.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { coerceArray, coerceBoolean } from './_coerce.js';
|
|
2
|
+
import { coerceArray, coerceBoolean, coerceNumber } from './_coerce.js';
|
|
3
3
|
import { drive as driveClient } from '@googleapis/drive';
|
|
4
4
|
import { accountAliasSchema, getAccountSet } from '../accounts.js';
|
|
5
5
|
import { getClient } from '../client.js';
|
|
@@ -129,7 +129,7 @@ export function registerDriveTools(server) {
|
|
|
129
129
|
inputSchema: {
|
|
130
130
|
account: accountEnum.describe('Google account alias'),
|
|
131
131
|
query: z.string().describe('A plain keyword (full-text search) or Drive query syntax, e.g. "name contains \'MoU\'"'),
|
|
132
|
-
maxResults: z.number().min(1).max(100)
|
|
132
|
+
maxResults: coerceNumber(z.number().min(1).max(100)).optional()
|
|
133
133
|
.describe('Max results to return (default: 10, max: 100)'),
|
|
134
134
|
driveId: z.string().optional().describe('Optional shared drive ID'),
|
|
135
135
|
},
|
package/dist/tools/gmail.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { coerceArray, coerceBoolean, coerceJson } from './_coerce.js';
|
|
2
|
+
import { coerceArray, coerceBoolean, coerceJson, coerceNumber } from './_coerce.js';
|
|
3
3
|
import { gmail as gmailClient } from '@googleapis/gmail';
|
|
4
4
|
import { accountAliasSchema } from '../accounts.js';
|
|
5
5
|
import { getClient } from '../client.js';
|
|
@@ -442,7 +442,7 @@ export function registerGmailTools(server) {
|
|
|
442
442
|
inputSchema: {
|
|
443
443
|
account: accountEnum.describe('Google account alias'),
|
|
444
444
|
query: z.string().describe('Gmail search syntax, e.g. "from:monaam is:unread"'),
|
|
445
|
-
maxResults: z.number().min(1).max(100)
|
|
445
|
+
maxResults: coerceNumber(z.number().min(1).max(100)).optional()
|
|
446
446
|
.describe('Max results to return (default: 20, max: 100)'),
|
|
447
447
|
full: coerceBoolean.optional().describe('Return the full row shape instead of the compact default'),
|
|
448
448
|
},
|
|
@@ -0,0 +1,98 @@
|
|
|
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): 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
|
+
/**
|
|
95
|
+
* null when off: no directory, no file, no timer, nothing initializes; setting
|
|
96
|
+
* USAGE_METRICS_PATH alone never enables anything and never creates anything.
|
|
97
|
+
*/
|
|
98
|
+
export declare function initUsageMetrics(state: UsageMetricsState, opts: InitOptions): Metrics | null;
|
|
@@ -0,0 +1,538 @@
|
|
|
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
|
+
/** Union of every `error:` slug literal emitted anywhere in src/ (kept honest
|
|
32
|
+
* by a set-equality grep test). Anything outside buckets to `other`. */
|
|
33
|
+
export const KNOWN_ERROR_SLUGS = new Set([
|
|
34
|
+
'E_CIMD_INVALID', 'E_MCP_TOKEN_INVALID', 'E_NO_DEFAULT_ACCOUNT', 'ambiguous',
|
|
35
|
+
'api_not_enabled', 'auth_required', 'binary', 'binary_unsupported',
|
|
36
|
+
'diagnose_failed', 'discovery_unavailable', 'dispatch_timeout', 'forbidden',
|
|
37
|
+
'insufficient_scope', 'internal', 'invalid_client', 'invalid_client_metadata',
|
|
38
|
+
'invalid_grant', 'invalid_params', 'invalid_query', 'invalid_request',
|
|
39
|
+
'invalid_scope', 'network_error', 'not_found', 'rate_limited',
|
|
40
|
+
'reauth_required', 'too_large', 'toolset_disabled', 'unknown_api',
|
|
41
|
+
'unknown_method', 'unsupported_grant_type', 'unsupported_type',
|
|
42
|
+
'untrusted_host', 'upstream_error', 'validation_error', 'write_disabled',
|
|
43
|
+
]);
|
|
44
|
+
const RPC_CODES = new Set([-32700, -32600, -32601, -32603]);
|
|
45
|
+
export function stateDir(env = process.env) {
|
|
46
|
+
return path.join(env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'mcp-google-multi');
|
|
47
|
+
}
|
|
48
|
+
export function latBucket(ms) {
|
|
49
|
+
for (const b of LAT_BOUNDS)
|
|
50
|
+
if (ms <= b)
|
|
51
|
+
return `le${b}`;
|
|
52
|
+
return 'gt30000';
|
|
53
|
+
}
|
|
54
|
+
export function charsBucket(n) {
|
|
55
|
+
for (const b of CHARS_BOUNDS)
|
|
56
|
+
if (n <= b)
|
|
57
|
+
return `le${b}`;
|
|
58
|
+
return 'gt65536';
|
|
59
|
+
}
|
|
60
|
+
export function fanBucket(width) {
|
|
61
|
+
if (width <= 1)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (width === 2)
|
|
64
|
+
return 'w2';
|
|
65
|
+
if (width <= 4)
|
|
66
|
+
return 'w3to4';
|
|
67
|
+
return 'w5plus';
|
|
68
|
+
}
|
|
69
|
+
const ON = new Set(['on', '1', 'true', 'yes']);
|
|
70
|
+
const OFF = new Set(['off', '0', 'false', 'no']);
|
|
71
|
+
/**
|
|
72
|
+
* Fail-closed resolution (mirror of resolveDiscoveryMode's fail-open: here the
|
|
73
|
+
* safe state is off). Env wins over config; empty env value = unset, silent;
|
|
74
|
+
* any other value = one warning, off. `envFile` is the .env path that supplied
|
|
75
|
+
* the variable when it did not come from the real process env (attribution
|
|
76
|
+
* is computed by the caller; this module never reads env files).
|
|
77
|
+
*/
|
|
78
|
+
export function resolveUsageMetrics(env, configValue, envFile) {
|
|
79
|
+
const raw = env.GOOGLE_USAGE_METRICS;
|
|
80
|
+
if (raw !== undefined && raw.trim() !== '') {
|
|
81
|
+
const v = raw.trim().toLowerCase();
|
|
82
|
+
const source = envFile ? { kind: 'env-file', file: envFile } : { kind: 'process-env' };
|
|
83
|
+
if (ON.has(v))
|
|
84
|
+
return { enabled: true, source };
|
|
85
|
+
if (OFF.has(v))
|
|
86
|
+
return { enabled: false, source };
|
|
87
|
+
return {
|
|
88
|
+
enabled: false,
|
|
89
|
+
source: { kind: 'default' },
|
|
90
|
+
warning: `GOOGLE_USAGE_METRICS="${raw}" is not a valid value (on|1|true|yes / off|0|false|no); local usage metrics stay OFF\n`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (configValue === true)
|
|
94
|
+
return { enabled: true, source: { kind: 'config' } };
|
|
95
|
+
return { enabled: false, source: { kind: 'default' } };
|
|
96
|
+
}
|
|
97
|
+
export function sourceLabel(source) {
|
|
98
|
+
switch (source.kind) {
|
|
99
|
+
case 'config': return '(config)';
|
|
100
|
+
case 'process-env': return '(process env)';
|
|
101
|
+
case 'env-file': return `(env file: ${source.file})`;
|
|
102
|
+
default: return '(default)';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function emptyDay(day) {
|
|
106
|
+
return {
|
|
107
|
+
v: 1, day, boots: {}, node: process.version, calls: 0, tools: {},
|
|
108
|
+
hints: {}, retries: {},
|
|
109
|
+
escape: { methods: {}, _overflow: 0, unknown_method: 0, apis_searched: {}, unknown_api: 0 },
|
|
110
|
+
validation: {}, rpc: {}, bigrams: {},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function addInto(target, key, by = 1) {
|
|
114
|
+
target[key] = (target[key] ?? 0) + by;
|
|
115
|
+
}
|
|
116
|
+
/** Deep-add b into a for the DayAgg shape (numbers add, maps union). */
|
|
117
|
+
function mergeDay(a, b) {
|
|
118
|
+
const num = (x, y) => (x ?? 0) + (y ?? 0);
|
|
119
|
+
const map = (x = {}, y = {}) => {
|
|
120
|
+
const out = { ...x };
|
|
121
|
+
for (const [k, v] of Object.entries(y))
|
|
122
|
+
out[k] = (out[k] ?? 0) + v;
|
|
123
|
+
return out;
|
|
124
|
+
};
|
|
125
|
+
const out = emptyDay(a.day);
|
|
126
|
+
out.node = b.node || a.node;
|
|
127
|
+
out.boots = map(a.boots, b.boots);
|
|
128
|
+
out.calls = num(a.calls, b.calls);
|
|
129
|
+
const toolNames = new Set([...Object.keys(a.tools), ...Object.keys(b.tools)]);
|
|
130
|
+
for (const name of toolNames) {
|
|
131
|
+
const x = a.tools[name];
|
|
132
|
+
const y = b.tools[name];
|
|
133
|
+
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) };
|
|
134
|
+
if (x?.fanout || y?.fanout)
|
|
135
|
+
t.fanout = map(x?.fanout, y?.fanout);
|
|
136
|
+
const argfix = num(x?.argfix, y?.argfix);
|
|
137
|
+
if (argfix > 0)
|
|
138
|
+
t.argfix = argfix;
|
|
139
|
+
out.tools[name] = t;
|
|
140
|
+
}
|
|
141
|
+
const slugSet = new Set([...Object.keys(a.hints), ...Object.keys(b.hints)]);
|
|
142
|
+
for (const s of slugSet) {
|
|
143
|
+
out.hints[s] = {
|
|
144
|
+
hinted: num(a.hints[s]?.hinted, b.hints[s]?.hinted),
|
|
145
|
+
unhinted: num(a.hints[s]?.unhinted, b.hints[s]?.unhinted),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const retrySet = new Set([...Object.keys(a.retries), ...Object.keys(b.retries)]);
|
|
149
|
+
for (const s of retrySet) {
|
|
150
|
+
out.retries[s] = {
|
|
151
|
+
hintedOk: num(a.retries[s]?.hintedOk, b.retries[s]?.hintedOk),
|
|
152
|
+
hintedFail: num(a.retries[s]?.hintedFail, b.retries[s]?.hintedFail),
|
|
153
|
+
unhintedOk: num(a.retries[s]?.unhintedOk, b.retries[s]?.unhintedOk),
|
|
154
|
+
unhintedFail: num(a.retries[s]?.unhintedFail, b.retries[s]?.unhintedFail),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
out.escape = {
|
|
158
|
+
methods: map(a.escape.methods, b.escape.methods),
|
|
159
|
+
_overflow: num(a.escape._overflow, b.escape._overflow),
|
|
160
|
+
unknown_method: num(a.escape.unknown_method, b.escape.unknown_method),
|
|
161
|
+
apis_searched: map(a.escape.apis_searched, b.escape.apis_searched),
|
|
162
|
+
unknown_api: num(a.escape.unknown_api, b.escape.unknown_api),
|
|
163
|
+
};
|
|
164
|
+
out.validation = map(a.validation, b.validation);
|
|
165
|
+
out.rpc = map(a.rpc, b.rpc);
|
|
166
|
+
out.bigrams = map(a.bigrams, b.bigrams);
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
/** Cap open maps at write time (the file may never exceed the caps). */
|
|
170
|
+
function applyCaps(day) {
|
|
171
|
+
const cap = (m, limit, overflowInto) => {
|
|
172
|
+
const entries = Object.entries(m);
|
|
173
|
+
if (entries.length <= limit)
|
|
174
|
+
return m;
|
|
175
|
+
entries.sort((x, y) => y[1] - x[1]);
|
|
176
|
+
const kept = Object.fromEntries(entries.slice(0, limit));
|
|
177
|
+
const dropped = entries.slice(limit).reduce((s, [, v]) => s + v, 0);
|
|
178
|
+
if (overflowInto)
|
|
179
|
+
overflowInto(dropped);
|
|
180
|
+
else
|
|
181
|
+
kept.other = (kept.other ?? 0) + dropped;
|
|
182
|
+
return kept;
|
|
183
|
+
};
|
|
184
|
+
for (const t of Object.values(day.tools))
|
|
185
|
+
t.err = cap(t.err, ERR_SLUG_CAP);
|
|
186
|
+
day.escape.methods = cap(day.escape.methods, ESCAPE_CAP, (n) => { day.escape._overflow += n; });
|
|
187
|
+
day.bigrams = cap(day.bigrams, BIGRAM_CAP, (n) => { day.bigrams._overflow = (day.bigrams._overflow ?? 0) + n; });
|
|
188
|
+
return day;
|
|
189
|
+
}
|
|
190
|
+
export class Metrics {
|
|
191
|
+
dir;
|
|
192
|
+
aggDir;
|
|
193
|
+
eventsFile;
|
|
194
|
+
now;
|
|
195
|
+
mono;
|
|
196
|
+
log;
|
|
197
|
+
bootKey;
|
|
198
|
+
bootId = randomBytes(3).toString('hex');
|
|
199
|
+
seq = 0;
|
|
200
|
+
deltas;
|
|
201
|
+
events = [];
|
|
202
|
+
lastError = new Map();
|
|
203
|
+
lastTool;
|
|
204
|
+
pendingMid;
|
|
205
|
+
timer;
|
|
206
|
+
dirty = false;
|
|
207
|
+
failures = 0;
|
|
208
|
+
disabled = false;
|
|
209
|
+
constructor(opts) {
|
|
210
|
+
this.dir = opts.dir ?? path.join(stateDir(opts.env), METRICS_DIR_NAME);
|
|
211
|
+
this.aggDir = path.join(this.dir, 'agg');
|
|
212
|
+
this.eventsFile = path.join(this.dir, 'events.jsonl');
|
|
213
|
+
this.now = opts.now ?? Date.now;
|
|
214
|
+
this.mono = opts.monotonic ?? (() => performance.now());
|
|
215
|
+
this.log = opts.log ?? ((l) => process.stderr.write(l));
|
|
216
|
+
this.bootKey = `${opts.version}/${opts.mode}/${opts.transport}`;
|
|
217
|
+
this.deltas = emptyDay(this.day());
|
|
218
|
+
this.deltas.boots[this.bootKey] = 1;
|
|
219
|
+
this.dirty = true;
|
|
220
|
+
this.initStorage();
|
|
221
|
+
this.timer = setInterval(() => this.flush(), FLUSH_MS);
|
|
222
|
+
this.timer.unref?.();
|
|
223
|
+
}
|
|
224
|
+
day() {
|
|
225
|
+
return new Date(this.now()).toISOString().slice(0, 10);
|
|
226
|
+
}
|
|
227
|
+
initStorage() {
|
|
228
|
+
try {
|
|
229
|
+
const existed = fs.existsSync(this.dir);
|
|
230
|
+
fs.mkdirSync(this.aggDir, { recursive: true, mode: 0o700 });
|
|
231
|
+
if (existed) {
|
|
232
|
+
const mode = fs.statSync(this.dir).mode & 0o777;
|
|
233
|
+
if ((mode & 0o077) !== 0) {
|
|
234
|
+
this.log(`local usage metrics: ${this.dir} has mode ${mode.toString(8)} (wider than 0700); not changing it, but the files record activity\n`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
this.pruneAggs();
|
|
238
|
+
this.pruneEventAge(this.eventsFile);
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
this.log(`local usage metrics: init failed (${e.message}); disabled for this process\n`);
|
|
242
|
+
this.disabled = true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
pruneAggs() {
|
|
246
|
+
const cutoff = this.day2ms(this.day()) - RETENTION_DAYS * 86_400_000;
|
|
247
|
+
for (const f of fs.readdirSync(this.aggDir)) {
|
|
248
|
+
const m = f.match(/^(\d{4}-\d{2}-\d{2})\.json$/);
|
|
249
|
+
if (m && this.day2ms(m[1]) < cutoff) {
|
|
250
|
+
try {
|
|
251
|
+
fs.rmSync(path.join(this.aggDir, f));
|
|
252
|
+
}
|
|
253
|
+
catch { /* prune is best-effort */ }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
day2ms(day) {
|
|
258
|
+
return Date.parse(`${day}T00:00:00Z`);
|
|
259
|
+
}
|
|
260
|
+
pruneEventAge(file) {
|
|
261
|
+
if (!fs.existsSync(file))
|
|
262
|
+
return;
|
|
263
|
+
const cutoff = this.now() - RETENTION_DAYS * 86_400_000;
|
|
264
|
+
const lines = fs.readFileSync(file, 'utf-8').split('\n');
|
|
265
|
+
const kept = lines.filter((l) => {
|
|
266
|
+
if (!l.trim())
|
|
267
|
+
return false;
|
|
268
|
+
try {
|
|
269
|
+
const ts = JSON.parse(l).ts;
|
|
270
|
+
return typeof ts === 'string' && Date.parse(ts) >= cutoff;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return false; // torn or corrupt line: drop
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
if (kept.length !== lines.filter((l) => l.trim()).length) {
|
|
277
|
+
atomicWriteFileSync(file, kept.join('\n') + (kept.length ? '\n' : ''));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Outermost dispatch wrapper; a throw inside recording never affects the call. */
|
|
281
|
+
wrap(entry, handler, widthOf) {
|
|
282
|
+
return async (...args) => {
|
|
283
|
+
const started = this.mono();
|
|
284
|
+
const result = await handler(...args);
|
|
285
|
+
try {
|
|
286
|
+
this.record(entry, args[0], result, this.mono() - started, widthOf);
|
|
287
|
+
}
|
|
288
|
+
catch { /* metrics may never fail a tool call */ }
|
|
289
|
+
return result;
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
record(entry, firstArg, result, ms, widthOf) {
|
|
293
|
+
if (this.disabled)
|
|
294
|
+
return;
|
|
295
|
+
this.rolloverIfNeeded();
|
|
296
|
+
const r = result;
|
|
297
|
+
const ok = !r?.isError;
|
|
298
|
+
let slug;
|
|
299
|
+
let hinted;
|
|
300
|
+
if (!ok) {
|
|
301
|
+
try {
|
|
302
|
+
const first = r?.content?.[0]?.text;
|
|
303
|
+
if (typeof first === 'string' && first.length < 65_536) {
|
|
304
|
+
const env = JSON.parse(first);
|
|
305
|
+
if (typeof env.error === 'string')
|
|
306
|
+
slug = KNOWN_ERROR_SLUGS.has(env.error) ? env.error : 'other';
|
|
307
|
+
hinted = typeof env.hint === 'string' && env.hint.length > 0;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
slug = 'other';
|
|
312
|
+
}
|
|
313
|
+
slug ??= 'other';
|
|
314
|
+
}
|
|
315
|
+
const chars = (r?.content ?? []).reduce((s, c) => s + (typeof c.text === 'string' ? c.text.length : 0), 0);
|
|
316
|
+
let width = 1;
|
|
317
|
+
if (widthOf) {
|
|
318
|
+
try {
|
|
319
|
+
width = widthOf(firstArg);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
width = 1;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const fan = fanBucket(width);
|
|
326
|
+
const t = (this.deltas.tools[entry.name] ??= { n: 0, err: {}, hint: 0, lat: {} });
|
|
327
|
+
t.n += 1;
|
|
328
|
+
this.deltas.calls += 1;
|
|
329
|
+
addInto(t.lat, latBucket(ms));
|
|
330
|
+
if (slug) {
|
|
331
|
+
addInto(t.err, slug);
|
|
332
|
+
if (hinted)
|
|
333
|
+
t.hint += 1;
|
|
334
|
+
const h = (this.deltas.hints[slug] ??= { hinted: 0, unhinted: 0 });
|
|
335
|
+
if (hinted)
|
|
336
|
+
h.hinted += 1;
|
|
337
|
+
else
|
|
338
|
+
h.unhinted += 1;
|
|
339
|
+
}
|
|
340
|
+
if (fan) {
|
|
341
|
+
const f = (t.fanout ??= { calls: 0 });
|
|
342
|
+
f.calls += 1;
|
|
343
|
+
addInto(f, fan);
|
|
344
|
+
}
|
|
345
|
+
// retry self-correction chain (spec section 1): in-memory only.
|
|
346
|
+
const nowM = this.mono();
|
|
347
|
+
const prev = this.lastError.get(entry.name);
|
|
348
|
+
if (prev && nowM - prev.at <= RETRY_WINDOW_MS) {
|
|
349
|
+
const rr = (this.deltas.retries[prev.slug] ??= { hintedOk: 0, hintedFail: 0, unhintedOk: 0, unhintedFail: 0 });
|
|
350
|
+
const key = `${prev.hinted ? 'hinted' : 'unhinted'}${ok ? 'Ok' : 'Fail'}`;
|
|
351
|
+
rr[key] += 1;
|
|
352
|
+
this.lastError.delete(entry.name);
|
|
353
|
+
}
|
|
354
|
+
if (!ok && slug)
|
|
355
|
+
this.lastError.set(entry.name, { slug, hinted: hinted === true, at: nowM });
|
|
356
|
+
// bigrams: ordered pairs within the process, 5-minute gap breaks the chain.
|
|
357
|
+
if (this.lastTool && nowM - this.lastTool.at <= BIGRAM_GAP_MS) {
|
|
358
|
+
addInto(this.deltas.bigrams, `${this.lastTool.name}>${entry.name}`);
|
|
359
|
+
}
|
|
360
|
+
this.lastTool = { name: entry.name, at: nowM };
|
|
361
|
+
const event = {
|
|
362
|
+
ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
|
|
363
|
+
boot: this.bootId,
|
|
364
|
+
seq: this.seq++,
|
|
365
|
+
tool: entry.name,
|
|
366
|
+
ok,
|
|
367
|
+
ms: Math.round(ms),
|
|
368
|
+
chars: charsBucket(chars),
|
|
369
|
+
src: 'handler',
|
|
370
|
+
};
|
|
371
|
+
if (slug)
|
|
372
|
+
event.err = slug;
|
|
373
|
+
if (hinted !== undefined)
|
|
374
|
+
event.hint = hinted;
|
|
375
|
+
if (fan)
|
|
376
|
+
event.fan = fan;
|
|
377
|
+
// mid: set mid-dispatch by recordEscapeMethod, attached to the escape
|
|
378
|
+
// call's own event line (per-event field, never a synthetic event).
|
|
379
|
+
if (entry.name === 'google_api_call' && this.pendingMid) {
|
|
380
|
+
event.mid = this.pendingMid;
|
|
381
|
+
this.pendingMid = undefined;
|
|
382
|
+
}
|
|
383
|
+
this.events.push(JSON.stringify(event));
|
|
384
|
+
this.dirty = true;
|
|
385
|
+
}
|
|
386
|
+
/** Escape hatch: only the RESOLVED methodId (double-gated) or a miss. */
|
|
387
|
+
recordEscapeMethod(idOrNull) {
|
|
388
|
+
try {
|
|
389
|
+
this.rolloverIfNeeded();
|
|
390
|
+
if (idOrNull === null) {
|
|
391
|
+
this.deltas.escape.unknown_method += 1;
|
|
392
|
+
}
|
|
393
|
+
else if (idOrNull.length <= METHOD_ID_MAX && METHOD_ID_RE.test(idOrNull)) {
|
|
394
|
+
addInto(this.deltas.escape.methods, idOrNull);
|
|
395
|
+
this.pendingMid = idOrNull;
|
|
396
|
+
}
|
|
397
|
+
this.dirty = true;
|
|
398
|
+
}
|
|
399
|
+
catch { /* never throws outward */ }
|
|
400
|
+
}
|
|
401
|
+
/** Search instrument: post-resolution SUPPORTED_APIS keys, or null on failure. */
|
|
402
|
+
recordSearchApi(resolvedApis) {
|
|
403
|
+
try {
|
|
404
|
+
this.rolloverIfNeeded();
|
|
405
|
+
if (resolvedApis === null)
|
|
406
|
+
this.deltas.escape.unknown_api += 1;
|
|
407
|
+
else
|
|
408
|
+
for (const k of resolvedApis)
|
|
409
|
+
addInto(this.deltas.escape.apis_searched, k);
|
|
410
|
+
this.dirty = true;
|
|
411
|
+
}
|
|
412
|
+
catch { /* never throws outward */ }
|
|
413
|
+
}
|
|
414
|
+
recordArgFix(tool) {
|
|
415
|
+
try {
|
|
416
|
+
this.rolloverIfNeeded();
|
|
417
|
+
const t = (this.deltas.tools[tool] ??= { n: 0, err: {}, hint: 0, lat: {} });
|
|
418
|
+
t.argfix = (t.argfix ?? 0) + 1;
|
|
419
|
+
this.dirty = true;
|
|
420
|
+
}
|
|
421
|
+
catch { /* never throws outward */ }
|
|
422
|
+
}
|
|
423
|
+
/** Protocol-level failures (no handler ran); wired by the transport tap. */
|
|
424
|
+
recordRpc(kind, tool) {
|
|
425
|
+
try {
|
|
426
|
+
this.rolloverIfNeeded();
|
|
427
|
+
if (kind === 'schema_validation') {
|
|
428
|
+
if (tool)
|
|
429
|
+
addInto(this.deltas.validation, tool);
|
|
430
|
+
}
|
|
431
|
+
else if (kind === 'tool_not_found') {
|
|
432
|
+
addInto(this.deltas.rpc, 'tool_not_found');
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
addInto(this.deltas.rpc, RPC_CODES.has(kind) ? `rpc_error_${kind}` : 'rpc_error_other');
|
|
436
|
+
}
|
|
437
|
+
this.events.push(JSON.stringify({
|
|
438
|
+
ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
|
|
439
|
+
boot: this.bootId, seq: this.seq++,
|
|
440
|
+
tool: tool ?? 'other', ok: false, ms: 0, chars: 'le64', src: 'rpc',
|
|
441
|
+
}));
|
|
442
|
+
this.dirty = true;
|
|
443
|
+
}
|
|
444
|
+
catch { /* never throws outward */ }
|
|
445
|
+
}
|
|
446
|
+
rolloverIfNeeded() {
|
|
447
|
+
const today = this.day();
|
|
448
|
+
if (this.deltas.day !== today) {
|
|
449
|
+
this.flush();
|
|
450
|
+
this.deltas = emptyDay(today);
|
|
451
|
+
this.pruneAggs();
|
|
452
|
+
this.dirty = false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/** Merge deltas into the day file under lock; append buffered events. */
|
|
456
|
+
flush() {
|
|
457
|
+
if (!this.dirty || this.disabled)
|
|
458
|
+
return;
|
|
459
|
+
const deltas = this.deltas;
|
|
460
|
+
const events = this.events;
|
|
461
|
+
try {
|
|
462
|
+
const file = path.join(this.aggDir, `${deltas.day}.json`);
|
|
463
|
+
withFileLock(file, () => {
|
|
464
|
+
let current = emptyDay(deltas.day);
|
|
465
|
+
try {
|
|
466
|
+
const onDisk = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
467
|
+
if (onDisk && onDisk.v === 1 && onDisk.day === deltas.day)
|
|
468
|
+
current = onDisk;
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
if (fs.existsSync(file))
|
|
472
|
+
this.log(`local usage metrics: unreadable day file ${file}; starting fresh\n`);
|
|
473
|
+
}
|
|
474
|
+
atomicWriteFileSync(file, JSON.stringify(applyCaps(mergeDay(current, deltas))));
|
|
475
|
+
});
|
|
476
|
+
if (events.length > 0) {
|
|
477
|
+
fs.appendFileSync(this.eventsFile, events.join('\n') + '\n', { mode: 0o600 });
|
|
478
|
+
this.rotateIfNeeded();
|
|
479
|
+
}
|
|
480
|
+
this.deltas = emptyDay(deltas.day);
|
|
481
|
+
this.events = [];
|
|
482
|
+
this.dirty = false;
|
|
483
|
+
this.failures = 0;
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
this.failures += 1;
|
|
487
|
+
if (this.failures >= WRITE_FAILURE_LIMIT) {
|
|
488
|
+
this.disabled = true;
|
|
489
|
+
if (this.timer)
|
|
490
|
+
clearInterval(this.timer);
|
|
491
|
+
this.log(`local usage metrics: ${WRITE_FAILURE_LIMIT} consecutive write failures (${e.message}); disabled for this process\n`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
rotateIfNeeded() {
|
|
496
|
+
try {
|
|
497
|
+
if (fs.statSync(this.eventsFile).size < EVENTS_ROTATE_BYTES)
|
|
498
|
+
return;
|
|
499
|
+
this.pruneEventAge(this.eventsFile);
|
|
500
|
+
if (fs.statSync(this.eventsFile).size < EVENTS_ROTATE_BYTES)
|
|
501
|
+
return;
|
|
502
|
+
fs.renameSync(this.eventsFile, path.join(this.dir, 'events.1.jsonl'));
|
|
503
|
+
}
|
|
504
|
+
catch { /* rotation is best-effort */ }
|
|
505
|
+
}
|
|
506
|
+
/** Best-effort synchronous flush for shutdown paths. */
|
|
507
|
+
shutdown() {
|
|
508
|
+
if (this.timer)
|
|
509
|
+
clearInterval(this.timer);
|
|
510
|
+
this.flush();
|
|
511
|
+
}
|
|
512
|
+
/** For the doctor/diagnose status line. */
|
|
513
|
+
statusLine() {
|
|
514
|
+
try {
|
|
515
|
+
const files = [
|
|
516
|
+
...fs.readdirSync(this.aggDir).map((f) => path.join(this.aggDir, f)),
|
|
517
|
+
this.eventsFile, path.join(this.dir, 'events.1.jsonl'),
|
|
518
|
+
].filter((f) => fs.existsSync(f));
|
|
519
|
+
const bytes = files.reduce((s, f) => s + fs.statSync(f).size, 0);
|
|
520
|
+
return `${this.dir} (${files.length} files, ${Math.round(bytes / 1024)} KB)`;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return this.dir;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* null when off: no directory, no file, no timer, nothing initializes; setting
|
|
529
|
+
* USAGE_METRICS_PATH alone never enables anything and never creates anything.
|
|
530
|
+
*/
|
|
531
|
+
export function initUsageMetrics(state, opts) {
|
|
532
|
+
if (!state.enabled)
|
|
533
|
+
return null;
|
|
534
|
+
return new Metrics({
|
|
535
|
+
...opts,
|
|
536
|
+
dir: opts.dir ?? (opts.env ?? process.env).USAGE_METRICS_PATH ?? undefined,
|
|
537
|
+
});
|
|
538
|
+
}
|
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.25",
|
|
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",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"gen:discovery": "tsx scripts/fetch-discovery.ts",
|
|
59
59
|
"gen:tools": "tsx scripts/gen-tools.ts",
|
|
60
60
|
"gen:coverage": "tsx scripts/gen-coverage.ts",
|
|
61
|
+
"eval:contract": "npx --yes promptfoo@0.123.1 eval -c eval/contract/promptfooconfig.yaml --no-progress-bar",
|
|
61
62
|
"measure:lazy": "node scripts/measure-tools.mjs",
|
|
62
63
|
"pack:mcpb": "mcpb pack . mcp-google-multi.mcpb",
|
|
63
64
|
"bundle": "esbuild dist/index.js --bundle --platform=node --format=esm --outfile=bundle/index.js --external:@napi-rs/keyring --banner:js=\"import{createRequire as __dockerCR}from'node:module';var require=__dockerCR(import.meta.url);\" --log-level=warning"
|