mcpfy-pulse 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mcpfy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # mcpfy-pulse
2
+
3
+ Telemetry for MCP servers. Captures method names, payload sizes, durations, and outcomes
4
+ for every JSON-RPC request an MCP server handles — never argument values, never resource
5
+ content. See [`telemetry-master-plan.md`](../../../../telemetry-master-plan.md) for the
6
+ full design.
7
+
8
+ There are exactly three ways to use this. Pick the one that matches your situation — none
9
+ of them edit your files for you.
10
+
11
+ ## 1. You're using `mcpfy-sdk`
12
+
13
+ Nothing to install or import. Set one environment variable:
14
+
15
+ ```bash
16
+ MCPFY_API_KEY=mk_live_xxx node dist/server.js
17
+ ```
18
+
19
+ `mcpfy-sdk` checks for `MCPFY_API_KEY` internally and wraps its own transport
20
+ automatically. Unset the variable and nothing changes — no code path is even touched.
21
+
22
+ ## 2. You have a raw `@modelcontextprotocol/sdk` server (source available)
23
+
24
+ ```ts
25
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26
+ import { withMcpfyTelemetry } from "mcpfy-pulse";
27
+
28
+ const transport = new StdioServerTransport();
29
+ await server.connect(
30
+ withMcpfyTelemetry(transport, { apiKey: process.env.MCPFY_API_KEY })
31
+ );
32
+ ```
33
+
34
+ `withMcpfyTelemetry` wraps the `Transport`'s `onmessage`/`send` seam — the two points
35
+ every JSON-RPC message passes through regardless of which SDK built the server. If
36
+ `apiKey` is unset, it returns the original transport unchanged.
37
+
38
+ ## 3. You're running someone else's server locally (no source access)
39
+
40
+ Edit your MCP client's config (`claude_desktop_config.json`, Cursor's `mcp.json`, etc.)
41
+ to route the command through the proxy:
42
+
43
+ ```jsonc
44
+ // before:
45
+ "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] }
46
+
47
+ // after:
48
+ "github": {
49
+ "command": "npx",
50
+ "args": ["-y", "mcpfy-proxy", "--", "npx", "-y", "@modelcontextprotocol/server-github"],
51
+ "env": { "MCPFY_API_KEY": "mk_live_xxx" }
52
+ }
53
+ ```
54
+
55
+ `mcpfy-proxy` becomes the process your client spawns. It spawns the real command as its
56
+ own child, sits in that child's stdin/stdout, and forwards every byte unchanged while
57
+ classifying JSON-RPC messages on the side. Works for any language — Python, Go, Rust,
58
+ anything — since it only ever reads newline-delimited JSON off a pipe.
59
+
60
+ ## What gets sent
61
+
62
+ One event per completed request:
63
+
64
+ ```json
65
+ {
66
+ "method": "tools/call",
67
+ "toolName": "create_pull_request",
68
+ "argsBytes": 312,
69
+ "resultBytes": 1024,
70
+ "durationMs": 1840,
71
+ "outcome": "ok",
72
+ "timestamp": "2026-08-06T10:23:15.000Z"
73
+ }
74
+ ```
75
+
76
+ Batched and POSTed every 5 seconds (or every 500 events, whichever comes first) to
77
+ `MCPFY_TELEMETRY_ENDPOINT` (defaults to the MCPFY ingest URL). If the request fails —
78
+ including "the endpoint doesn't exist yet," which is currently true, see the master
79
+ plan §4/§7 — the batch is silently dropped. Telemetry never throws, never retries
80
+ indefinitely, and never delays or blocks the actual MCP traffic it's observing.
81
+
82
+ ## Environment variables
83
+
84
+ | Variable | Purpose |
85
+ |---|---|
86
+ | `MCPFY_API_KEY` | Required for any telemetry to be sent. Unset = no-op everywhere. |
87
+ | `MCPFY_TELEMETRY_ENDPOINT` | Override the ingest URL (e.g. for local testing). |
88
+ | `MCPFY_GATEWAY` | Set internally by MCP-backend for gateway-routed servers, which already log through `McpGatewayLogger` — `mcpfy-sdk` skips wrapping when this is set, to avoid double-counting. Not something you set yourself. |
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/proxy/run.ts
4
+ import { spawn } from "child_process";
5
+
6
+ // src/config.ts
7
+ var DEFAULT_ENDPOINT = "https://api.mcpfy.ai/v1/telemetry/ingest";
8
+ var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
9
+ var DEFAULT_MAX_BATCH_SIZE = 500;
10
+ function resolveConfig(options) {
11
+ return {
12
+ apiKey: options?.apiKey ?? process.env.MCPFY_API_KEY,
13
+ endpoint: options?.endpoint ?? process.env.MCPFY_TELEMETRY_ENDPOINT ?? DEFAULT_ENDPOINT,
14
+ flushIntervalMs: options?.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
15
+ maxBatchSize: options?.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE
16
+ };
17
+ }
18
+
19
+ // src/core/classify.ts
20
+ function byteLength(value) {
21
+ if (value === void 0) return 0;
22
+ try {
23
+ return Buffer.byteLength(JSON.stringify(value));
24
+ } catch {
25
+ return 0;
26
+ }
27
+ }
28
+ function extractLabel(method, params) {
29
+ switch (method) {
30
+ case "tools/call":
31
+ return { toolName: params?.name };
32
+ case "prompts/get":
33
+ return { promptName: params?.name };
34
+ case "resources/read":
35
+ return { resourceUri: params?.uri };
36
+ case "initialize":
37
+ return {
38
+ clientName: params?.clientInfo?.name,
39
+ clientVersion: params?.clientInfo?.version,
40
+ protocolVersion: params?.protocolVersion
41
+ };
42
+ default:
43
+ return {};
44
+ }
45
+ }
46
+ var MessageClassifier = class {
47
+ pending = /* @__PURE__ */ new Map();
48
+ onIncoming(message) {
49
+ if (!message || typeof message !== "object") return;
50
+ const { id, method, params } = message;
51
+ if (id === void 0 || !method) return;
52
+ this.pending.set(id, {
53
+ method,
54
+ startedAt: Date.now(),
55
+ argsBytes: byteLength(params),
56
+ extra: extractLabel(method, params)
57
+ });
58
+ }
59
+ onOutgoing(message) {
60
+ if (!message || typeof message !== "object") return void 0;
61
+ const { id, method, result, error } = message;
62
+ if (method || id === void 0) return void 0;
63
+ const pending = this.pending.get(id);
64
+ if (!pending) return void 0;
65
+ this.pending.delete(id);
66
+ const event = {
67
+ type: "request",
68
+ method: pending.method,
69
+ argsBytes: pending.argsBytes,
70
+ resultBytes: byteLength(error ?? result),
71
+ durationMs: Date.now() - pending.startedAt,
72
+ outcome: error ? "error" : "ok",
73
+ errorCode: error?.code,
74
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
75
+ ...pending.extra
76
+ };
77
+ if (pending.method === "initialize" && result?.serverInfo) {
78
+ event.serverName = result.serverInfo.name;
79
+ event.serverVersion = result.serverInfo.version;
80
+ }
81
+ return event;
82
+ }
83
+ };
84
+
85
+ // src/core/batcher.ts
86
+ var TelemetryBatcher = class {
87
+ constructor(config, meta) {
88
+ this.config = config;
89
+ this.meta = meta;
90
+ this.timer = setInterval(() => void this.flush(), config.flushIntervalMs);
91
+ this.timer.unref?.();
92
+ }
93
+ config;
94
+ meta;
95
+ queue = [];
96
+ timer;
97
+ push(event) {
98
+ this.queue.push(event);
99
+ if (this.queue.length >= this.config.maxBatchSize) void this.flush();
100
+ }
101
+ async flush() {
102
+ if (this.queue.length === 0) return;
103
+ const events = this.queue.splice(0, this.queue.length);
104
+ try {
105
+ await fetch(this.config.endpoint, {
106
+ method: "POST",
107
+ headers: {
108
+ "content-type": "application/json",
109
+ authorization: `Bearer ${this.config.apiKey ?? ""}`
110
+ },
111
+ body: JSON.stringify({
112
+ serverName: this.meta.serverName,
113
+ serverVersion: this.meta.serverVersion,
114
+ sdkName: this.meta.sdkName,
115
+ sdkVersion: this.meta.sdkVersion,
116
+ installMode: this.meta.installMode,
117
+ events
118
+ })
119
+ });
120
+ } catch {
121
+ }
122
+ }
123
+ async close() {
124
+ clearInterval(this.timer);
125
+ await this.flush();
126
+ }
127
+ };
128
+
129
+ // src/proxy/run.ts
130
+ function tryParse(line) {
131
+ const trimmed = line.trim();
132
+ if (!trimmed) return void 0;
133
+ try {
134
+ return JSON.parse(trimmed);
135
+ } catch {
136
+ return void 0;
137
+ }
138
+ }
139
+ function pipeLines(source, dest, onLine) {
140
+ let buffer = "";
141
+ source.on("data", (chunk) => {
142
+ dest.write(chunk);
143
+ buffer += chunk.toString("utf8");
144
+ let newlineIndex;
145
+ while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
146
+ const line = buffer.slice(0, newlineIndex);
147
+ buffer = buffer.slice(newlineIndex + 1);
148
+ onLine(line);
149
+ }
150
+ });
151
+ }
152
+ async function runProxy(argv) {
153
+ const sepIndex = argv.indexOf("--");
154
+ if (sepIndex === -1 || sepIndex === argv.length - 1) {
155
+ process.stderr.write(
156
+ "Usage: mcpfy-proxy -- <command> [args...]\nExample: mcpfy-proxy -- npx -y @modelcontextprotocol/server-github\n"
157
+ );
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+ const [command, ...args] = argv.slice(sepIndex + 1);
162
+ const config = resolveConfig();
163
+ const classifier = new MessageClassifier();
164
+ const batcher = config.apiKey ? new TelemetryBatcher(config, { sdkName: "unknown", installMode: "stdio-proxy" }) : void 0;
165
+ const child = spawn(command, args, {
166
+ stdio: ["pipe", "pipe", "inherit"],
167
+ env: process.env
168
+ });
169
+ child.on("error", (err) => {
170
+ process.stderr.write(`mcpfy-proxy: failed to start "${command}": ${err.message}
171
+ `);
172
+ process.exitCode = 1;
173
+ });
174
+ pipeLines(process.stdin, child.stdin, (line) => {
175
+ const message = tryParse(line);
176
+ if (message) classifier.onIncoming(message);
177
+ });
178
+ pipeLines(child.stdout, process.stdout, (line) => {
179
+ const message = tryParse(line);
180
+ if (!message) return;
181
+ const event = classifier.onOutgoing(message);
182
+ if (event && batcher) batcher.push(event);
183
+ });
184
+ process.on("SIGINT", () => child.kill("SIGINT"));
185
+ process.on("SIGTERM", () => child.kill("SIGTERM"));
186
+ await new Promise((resolvePromise) => {
187
+ child.on("exit", (code, signal) => {
188
+ void batcher?.close().finally(() => {
189
+ if (signal) {
190
+ process.kill(process.pid, signal);
191
+ } else {
192
+ process.exitCode = code ?? 0;
193
+ }
194
+ resolvePromise();
195
+ });
196
+ });
197
+ });
198
+ }
199
+
200
+ // src/bin/mcpfy-proxy.ts
201
+ runProxy(process.argv.slice(2)).catch((err) => {
202
+ process.stderr.write(`mcpfy-proxy: ${err instanceof Error ? err.message : String(err)}
203
+ `);
204
+ process.exitCode = 1;
205
+ });
206
+ //# sourceMappingURL=mcpfy-proxy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/proxy/run.ts","../../src/config.ts","../../src/core/classify.ts","../../src/core/batcher.ts","../../src/bin/mcpfy-proxy.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\nimport { resolveConfig } from \"../config.js\";\nimport { MessageClassifier } from \"../core/classify.js\";\nimport { TelemetryBatcher } from \"../core/batcher.js\";\n\nfunction tryParse(line: string): any {\n const trimmed = line.trim();\n if (!trimmed) return undefined;\n try {\n return JSON.parse(trimmed);\n } catch {\n return undefined;\n }\n}\n\n/**\n * Forwards raw chunks to `dest` immediately and unchanged, then independently\n * re-buffers a copy into newline-delimited lines for classification. The pass-through\n * path never waits on parsing — a bug in classification can never corrupt or delay\n * the actual MCP traffic.\n */\nfunction pipeLines(source: NodeJS.ReadableStream, dest: NodeJS.WritableStream, onLine: (line: string) => void): void {\n let buffer = \"\";\n source.on(\"data\", (chunk: Buffer) => {\n dest.write(chunk);\n buffer += chunk.toString(\"utf8\");\n let newlineIndex: number;\n while ((newlineIndex = buffer.indexOf(\"\\n\")) !== -1) {\n const line = buffer.slice(0, newlineIndex);\n buffer = buffer.slice(newlineIndex + 1);\n onLine(line);\n }\n });\n}\n\n/**\n * `mcpfy-proxy -- <command> [args...]`\n *\n * Spawns <command> as a child process and sits in its stdin/stdout pipe. Client-to-server\n * messages (parent stdin -> child stdin) are classified as \"incoming\"; server-to-client\n * messages (child stdout -> parent stdout) are classified as \"outgoing\", mirroring\n * onmessage/send in the in-process wrapper. Works for any language — the proxy never\n * parses anything beyond newline-delimited JSON-RPC framing.\n */\nexport async function runProxy(argv: string[]): Promise<void> {\n const sepIndex = argv.indexOf(\"--\");\n if (sepIndex === -1 || sepIndex === argv.length - 1) {\n process.stderr.write(\n \"Usage: mcpfy-proxy -- <command> [args...]\\n\" +\n \"Example: mcpfy-proxy -- npx -y @modelcontextprotocol/server-github\\n\"\n );\n process.exitCode = 1;\n return;\n }\n\n const [command, ...args] = argv.slice(sepIndex + 1);\n const config = resolveConfig();\n const classifier = new MessageClassifier();\n const batcher = config.apiKey\n ? new TelemetryBatcher(config, { sdkName: \"unknown\", installMode: \"stdio-proxy\" })\n : undefined;\n\n const child = spawn(command, args, {\n stdio: [\"pipe\", \"pipe\", \"inherit\"],\n env: process.env,\n });\n\n child.on(\"error\", (err) => {\n process.stderr.write(`mcpfy-proxy: failed to start \"${command}\": ${err.message}\\n`);\n process.exitCode = 1;\n });\n\n pipeLines(process.stdin, child.stdin!, (line) => {\n const message = tryParse(line);\n if (message) classifier.onIncoming(message);\n });\n\n pipeLines(child.stdout!, process.stdout, (line) => {\n const message = tryParse(line);\n if (!message) return;\n const event = classifier.onOutgoing(message);\n if (event && batcher) batcher.push(event);\n });\n\n process.on(\"SIGINT\", () => child.kill(\"SIGINT\"));\n process.on(\"SIGTERM\", () => child.kill(\"SIGTERM\"));\n\n await new Promise<void>((resolvePromise) => {\n child.on(\"exit\", (code, signal) => {\n void batcher?.close().finally(() => {\n if (signal) {\n process.kill(process.pid, signal);\n } else {\n process.exitCode = code ?? 0;\n }\n resolvePromise();\n });\n });\n });\n}\n","import type { TelemetryOptions } from \"./types.js\";\n\n// Path matches the real route on cloudmcp-nest: POST /v1/telemetry/ingest.\nconst DEFAULT_ENDPOINT = \"https://api.mcpfy.ai/v1/telemetry/ingest\";\nconst DEFAULT_FLUSH_INTERVAL_MS = 5000;\nconst DEFAULT_MAX_BATCH_SIZE = 500;\n\nexport interface ResolvedConfig {\n apiKey: string | undefined;\n endpoint: string;\n flushIntervalMs: number;\n maxBatchSize: number;\n}\n\nexport function resolveConfig(options?: TelemetryOptions): ResolvedConfig {\n return {\n apiKey: options?.apiKey ?? process.env.MCPFY_API_KEY,\n endpoint: options?.endpoint ?? process.env.MCPFY_TELEMETRY_ENDPOINT ?? DEFAULT_ENDPOINT,\n flushIntervalMs: options?.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,\n maxBatchSize: options?.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE,\n };\n}\n","import type { TelemetryEvent } from \"../types.js\";\n\ninterface PendingEntry {\n method: string;\n startedAt: number;\n argsBytes: number;\n extra: Partial<TelemetryEvent>;\n}\n\nfunction byteLength(value: unknown): number {\n if (value === undefined) return 0;\n try {\n return Buffer.byteLength(JSON.stringify(value));\n } catch {\n return 0;\n }\n}\n\nfunction extractLabel(method: string, params: any): Partial<TelemetryEvent> {\n switch (method) {\n case \"tools/call\":\n return { toolName: params?.name };\n case \"prompts/get\":\n return { promptName: params?.name };\n case \"resources/read\":\n return { resourceUri: params?.uri };\n case \"initialize\":\n // Protocol handshake metadata, not user data — same fields the spec itself exchanges in the clear.\n return {\n clientName: params?.clientInfo?.name,\n clientVersion: params?.clientInfo?.version,\n protocolVersion: params?.protocolVersion,\n };\n default:\n return {};\n }\n}\n\n/**\n * Tracks request/response pairs across the two seams every Transport exposes\n * (onmessage = incoming, send = outgoing) and emits one event per completed\n * request. Only method names, byte counts, timing, and outcome are captured —\n * argument values and result content are never read beyond their byte length.\n *\n * Server-initiated requests/notifications (sampling, elicitation, logging,\n * progress) are intentionally not tracked yet — out of scope for this pass.\n */\nexport class MessageClassifier {\n private pending = new Map<string | number, PendingEntry>();\n\n onIncoming(message: any): void {\n if (!message || typeof message !== \"object\") return;\n const { id, method, params } = message;\n if (id === undefined || !method) return; // only requests carry both an id and a method\n this.pending.set(id, {\n method,\n startedAt: Date.now(),\n argsBytes: byteLength(params),\n extra: extractLabel(method, params),\n });\n }\n\n onOutgoing(message: any): TelemetryEvent | undefined {\n if (!message || typeof message !== \"object\") return undefined;\n const { id, method, result, error } = message;\n if (method || id === undefined) return undefined;\n\n const pending = this.pending.get(id);\n if (!pending) return undefined;\n this.pending.delete(id);\n\n const event: TelemetryEvent = {\n type: \"request\",\n method: pending.method,\n argsBytes: pending.argsBytes,\n resultBytes: byteLength(error ?? result),\n durationMs: Date.now() - pending.startedAt,\n outcome: error ? \"error\" : \"ok\",\n errorCode: error?.code,\n timestamp: new Date().toISOString(),\n ...pending.extra,\n };\n\n if (pending.method === \"initialize\" && result?.serverInfo) {\n event.serverName = result.serverInfo.name;\n event.serverVersion = result.serverInfo.version;\n }\n\n return event;\n }\n}\n","import type { TelemetryEvent, SdkName, InstallMode } from \"../types.js\";\nimport type { ResolvedConfig } from \"../config.js\";\n\nexport interface BatchMeta {\n serverName?: string;\n serverVersion?: string;\n sdkName: SdkName;\n sdkVersion?: string;\n installMode: InstallMode;\n}\n\n/**\n * Ring-buffer batcher: queues events, flushes on a timer or when full, and never\n * throws or retries indefinitely — a failed or unreachable ingest endpoint is a\n * silent no-op, by design (telemetry must never affect the server's own behavior,\n * and the endpoint may not exist yet — see telemetry-master-plan.md §4/§7).\n */\nexport class TelemetryBatcher {\n private queue: TelemetryEvent[] = [];\n private timer: ReturnType<typeof setInterval>;\n\n constructor(\n private readonly config: ResolvedConfig,\n private readonly meta: BatchMeta\n ) {\n this.timer = setInterval(() => void this.flush(), config.flushIntervalMs);\n this.timer.unref?.(); // telemetry must never be the reason a process stays alive\n }\n\n push(event: TelemetryEvent): void {\n this.queue.push(event);\n if (this.queue.length >= this.config.maxBatchSize) void this.flush();\n }\n\n async flush(): Promise<void> {\n if (this.queue.length === 0) return;\n const events = this.queue.splice(0, this.queue.length);\n try {\n await fetch(this.config.endpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.config.apiKey ?? \"\"}`,\n },\n body: JSON.stringify({\n serverName: this.meta.serverName,\n serverVersion: this.meta.serverVersion,\n sdkName: this.meta.sdkName,\n sdkVersion: this.meta.sdkVersion,\n installMode: this.meta.installMode,\n events,\n }),\n });\n } catch {\n // silent drop\n }\n }\n\n async close(): Promise<void> {\n clearInterval(this.timer);\n await this.flush();\n }\n}\n","#!/usr/bin/env node\nimport { runProxy } from \"../proxy/run.js\";\n\nrunProxy(process.argv.slice(2)).catch((err) => {\n process.stderr.write(`mcpfy-proxy: ${err instanceof Error ? err.message : String(err)}\\n`);\n process.exitCode = 1;\n});\n"],"mappings":"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","names":[]}
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=mcpfy-proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcpfy-proxy.d.ts","sourceRoot":"","sources":["../../../src/bin/mcpfy-proxy.ts"],"names":[],"mappings":""}
@@ -0,0 +1,9 @@
1
+ import type { TelemetryOptions } from "./types.js";
2
+ export interface ResolvedConfig {
3
+ apiKey: string | undefined;
4
+ endpoint: string;
5
+ flushIntervalMs: number;
6
+ maxBatchSize: number;
7
+ }
8
+ export declare function resolveConfig(options?: TelemetryOptions): ResolvedConfig;
9
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAOnD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,wBAAgB,aAAa,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,cAAc,CAOxE"}
@@ -0,0 +1,26 @@
1
+ import type { TelemetryEvent, SdkName, InstallMode } from "../types.js";
2
+ import type { ResolvedConfig } from "../config.js";
3
+ export interface BatchMeta {
4
+ serverName?: string;
5
+ serverVersion?: string;
6
+ sdkName: SdkName;
7
+ sdkVersion?: string;
8
+ installMode: InstallMode;
9
+ }
10
+ /**
11
+ * Ring-buffer batcher: queues events, flushes on a timer or when full, and never
12
+ * throws or retries indefinitely — a failed or unreachable ingest endpoint is a
13
+ * silent no-op, by design (telemetry must never affect the server's own behavior,
14
+ * and the endpoint may not exist yet — see telemetry-master-plan.md §4/§7).
15
+ */
16
+ export declare class TelemetryBatcher {
17
+ private readonly config;
18
+ private readonly meta;
19
+ private queue;
20
+ private timer;
21
+ constructor(config: ResolvedConfig, meta: BatchMeta);
22
+ push(event: TelemetryEvent): void;
23
+ flush(): Promise<void>;
24
+ close(): Promise<void>;
25
+ }
26
+ //# sourceMappingURL=batcher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batcher.d.ts","sourceRoot":"","sources":["../../../src/core/batcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD,MAAM,WAAW,SAAS;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,WAAW,CAAC;CAC1B;AAED;;;;;GAKG;AACH,qBAAa,gBAAgB;IAKzB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,IAAI;IALvB,OAAO,CAAC,KAAK,CAAwB;IACrC,OAAO,CAAC,KAAK,CAAiC;gBAG3B,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,SAAS;IAMlC,IAAI,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAK3B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAwBtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAI7B"}
@@ -0,0 +1,16 @@
1
+ import type { TelemetryEvent } from "../types.js";
2
+ /**
3
+ * Tracks request/response pairs across the two seams every Transport exposes
4
+ * (onmessage = incoming, send = outgoing) and emits one event per completed
5
+ * request. Only method names, byte counts, timing, and outcome are captured —
6
+ * argument values and result content are never read beyond their byte length.
7
+ *
8
+ * Server-initiated requests/notifications (sampling, elicitation, logging,
9
+ * progress) are intentionally not tracked yet — out of scope for this pass.
10
+ */
11
+ export declare class MessageClassifier {
12
+ private pending;
13
+ onIncoming(message: any): void;
14
+ onOutgoing(message: any): TelemetryEvent | undefined;
15
+ }
16
+ //# sourceMappingURL=classify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"classify.d.ts","sourceRoot":"","sources":["../../../src/core/classify.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAsClD;;;;;;;;GAQG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,OAAO,CAA4C;IAE3D,UAAU,CAAC,OAAO,EAAE,GAAG,GAAG,IAAI;IAY9B,UAAU,CAAC,OAAO,EAAE,GAAG,GAAG,cAAc,GAAG,SAAS;CA4BrD"}
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ withMcpfyTelemetry: () => withMcpfyTelemetry
24
+ });
25
+ module.exports = __toCommonJS(src_exports);
26
+
27
+ // src/config.ts
28
+ var DEFAULT_ENDPOINT = "https://api.mcpfy.ai/v1/telemetry/ingest";
29
+ var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
30
+ var DEFAULT_MAX_BATCH_SIZE = 500;
31
+ function resolveConfig(options) {
32
+ return {
33
+ apiKey: options?.apiKey ?? process.env.MCPFY_API_KEY,
34
+ endpoint: options?.endpoint ?? process.env.MCPFY_TELEMETRY_ENDPOINT ?? DEFAULT_ENDPOINT,
35
+ flushIntervalMs: options?.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
36
+ maxBatchSize: options?.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE
37
+ };
38
+ }
39
+
40
+ // src/core/classify.ts
41
+ function byteLength(value) {
42
+ if (value === void 0) return 0;
43
+ try {
44
+ return Buffer.byteLength(JSON.stringify(value));
45
+ } catch {
46
+ return 0;
47
+ }
48
+ }
49
+ function extractLabel(method, params) {
50
+ switch (method) {
51
+ case "tools/call":
52
+ return { toolName: params?.name };
53
+ case "prompts/get":
54
+ return { promptName: params?.name };
55
+ case "resources/read":
56
+ return { resourceUri: params?.uri };
57
+ case "initialize":
58
+ return {
59
+ clientName: params?.clientInfo?.name,
60
+ clientVersion: params?.clientInfo?.version,
61
+ protocolVersion: params?.protocolVersion
62
+ };
63
+ default:
64
+ return {};
65
+ }
66
+ }
67
+ var MessageClassifier = class {
68
+ pending = /* @__PURE__ */ new Map();
69
+ onIncoming(message) {
70
+ if (!message || typeof message !== "object") return;
71
+ const { id, method, params } = message;
72
+ if (id === void 0 || !method) return;
73
+ this.pending.set(id, {
74
+ method,
75
+ startedAt: Date.now(),
76
+ argsBytes: byteLength(params),
77
+ extra: extractLabel(method, params)
78
+ });
79
+ }
80
+ onOutgoing(message) {
81
+ if (!message || typeof message !== "object") return void 0;
82
+ const { id, method, result, error } = message;
83
+ if (method || id === void 0) return void 0;
84
+ const pending = this.pending.get(id);
85
+ if (!pending) return void 0;
86
+ this.pending.delete(id);
87
+ const event = {
88
+ type: "request",
89
+ method: pending.method,
90
+ argsBytes: pending.argsBytes,
91
+ resultBytes: byteLength(error ?? result),
92
+ durationMs: Date.now() - pending.startedAt,
93
+ outcome: error ? "error" : "ok",
94
+ errorCode: error?.code,
95
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
96
+ ...pending.extra
97
+ };
98
+ if (pending.method === "initialize" && result?.serverInfo) {
99
+ event.serverName = result.serverInfo.name;
100
+ event.serverVersion = result.serverInfo.version;
101
+ }
102
+ return event;
103
+ }
104
+ };
105
+
106
+ // src/core/batcher.ts
107
+ var TelemetryBatcher = class {
108
+ constructor(config, meta) {
109
+ this.config = config;
110
+ this.meta = meta;
111
+ this.timer = setInterval(() => void this.flush(), config.flushIntervalMs);
112
+ this.timer.unref?.();
113
+ }
114
+ config;
115
+ meta;
116
+ queue = [];
117
+ timer;
118
+ push(event) {
119
+ this.queue.push(event);
120
+ if (this.queue.length >= this.config.maxBatchSize) void this.flush();
121
+ }
122
+ async flush() {
123
+ if (this.queue.length === 0) return;
124
+ const events = this.queue.splice(0, this.queue.length);
125
+ try {
126
+ await fetch(this.config.endpoint, {
127
+ method: "POST",
128
+ headers: {
129
+ "content-type": "application/json",
130
+ authorization: `Bearer ${this.config.apiKey ?? ""}`
131
+ },
132
+ body: JSON.stringify({
133
+ serverName: this.meta.serverName,
134
+ serverVersion: this.meta.serverVersion,
135
+ sdkName: this.meta.sdkName,
136
+ sdkVersion: this.meta.sdkVersion,
137
+ installMode: this.meta.installMode,
138
+ events
139
+ })
140
+ });
141
+ } catch {
142
+ }
143
+ }
144
+ async close() {
145
+ clearInterval(this.timer);
146
+ await this.flush();
147
+ }
148
+ };
149
+
150
+ // src/wrap-transport.ts
151
+ function withMcpfyTelemetry(transport, options) {
152
+ const config = resolveConfig(options);
153
+ if (!config.apiKey) return transport;
154
+ const classifier = new MessageClassifier();
155
+ const batcher = new TelemetryBatcher(config, {
156
+ serverName: options?.serverName,
157
+ serverVersion: options?.serverVersion,
158
+ sdkName: options?.sdkName ?? "@modelcontextprotocol/sdk",
159
+ sdkVersion: options?.sdkVersion,
160
+ installMode: options?.installMode ?? "sdk-wrapper"
161
+ });
162
+ const wrapped = {
163
+ start: () => transport.start(),
164
+ close: () => {
165
+ void batcher.close();
166
+ return transport.close();
167
+ },
168
+ send: (message, sendOptions) => {
169
+ const event = classifier.onOutgoing(message);
170
+ if (event) batcher.push(event);
171
+ return transport.send(message, sendOptions);
172
+ },
173
+ get sessionId() {
174
+ return transport.sessionId;
175
+ },
176
+ get onclose() {
177
+ return transport.onclose;
178
+ },
179
+ set onclose(fn) {
180
+ transport.onclose = fn;
181
+ },
182
+ get onerror() {
183
+ return transport.onerror;
184
+ },
185
+ set onerror(fn) {
186
+ transport.onerror = fn;
187
+ },
188
+ get onmessage() {
189
+ return transport.onmessage;
190
+ },
191
+ set onmessage(handler) {
192
+ transport.onmessage = (message, extra) => {
193
+ classifier.onIncoming(message);
194
+ handler?.(message, extra);
195
+ };
196
+ }
197
+ };
198
+ return wrapped;
199
+ }
200
+ // Annotate the CommonJS export names for ESM import in node:
201
+ 0 && (module.exports = {
202
+ withMcpfyTelemetry
203
+ });
204
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/index.ts","../../src/config.ts","../../src/core/classify.ts","../../src/core/batcher.ts","../../src/wrap-transport.ts"],"sourcesContent":["export { withMcpfyTelemetry } from \"./wrap-transport.js\";\nexport type { TelemetryOptions, TelemetryEvent, MinimalTransport, SdkName, InstallMode } from \"./types.js\";\n","import type { TelemetryOptions } from \"./types.js\";\n\n// Path matches the real route on cloudmcp-nest: POST /v1/telemetry/ingest.\nconst DEFAULT_ENDPOINT = \"https://api.mcpfy.ai/v1/telemetry/ingest\";\nconst DEFAULT_FLUSH_INTERVAL_MS = 5000;\nconst DEFAULT_MAX_BATCH_SIZE = 500;\n\nexport interface ResolvedConfig {\n apiKey: string | undefined;\n endpoint: string;\n flushIntervalMs: number;\n maxBatchSize: number;\n}\n\nexport function resolveConfig(options?: TelemetryOptions): ResolvedConfig {\n return {\n apiKey: options?.apiKey ?? process.env.MCPFY_API_KEY,\n endpoint: options?.endpoint ?? process.env.MCPFY_TELEMETRY_ENDPOINT ?? DEFAULT_ENDPOINT,\n flushIntervalMs: options?.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,\n maxBatchSize: options?.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE,\n };\n}\n","import type { TelemetryEvent } from \"../types.js\";\n\ninterface PendingEntry {\n method: string;\n startedAt: number;\n argsBytes: number;\n extra: Partial<TelemetryEvent>;\n}\n\nfunction byteLength(value: unknown): number {\n if (value === undefined) return 0;\n try {\n return Buffer.byteLength(JSON.stringify(value));\n } catch {\n return 0;\n }\n}\n\nfunction extractLabel(method: string, params: any): Partial<TelemetryEvent> {\n switch (method) {\n case \"tools/call\":\n return { toolName: params?.name };\n case \"prompts/get\":\n return { promptName: params?.name };\n case \"resources/read\":\n return { resourceUri: params?.uri };\n case \"initialize\":\n // Protocol handshake metadata, not user data — same fields the spec itself exchanges in the clear.\n return {\n clientName: params?.clientInfo?.name,\n clientVersion: params?.clientInfo?.version,\n protocolVersion: params?.protocolVersion,\n };\n default:\n return {};\n }\n}\n\n/**\n * Tracks request/response pairs across the two seams every Transport exposes\n * (onmessage = incoming, send = outgoing) and emits one event per completed\n * request. Only method names, byte counts, timing, and outcome are captured —\n * argument values and result content are never read beyond their byte length.\n *\n * Server-initiated requests/notifications (sampling, elicitation, logging,\n * progress) are intentionally not tracked yet — out of scope for this pass.\n */\nexport class MessageClassifier {\n private pending = new Map<string | number, PendingEntry>();\n\n onIncoming(message: any): void {\n if (!message || typeof message !== \"object\") return;\n const { id, method, params } = message;\n if (id === undefined || !method) return; // only requests carry both an id and a method\n this.pending.set(id, {\n method,\n startedAt: Date.now(),\n argsBytes: byteLength(params),\n extra: extractLabel(method, params),\n });\n }\n\n onOutgoing(message: any): TelemetryEvent | undefined {\n if (!message || typeof message !== \"object\") return undefined;\n const { id, method, result, error } = message;\n if (method || id === undefined) return undefined;\n\n const pending = this.pending.get(id);\n if (!pending) return undefined;\n this.pending.delete(id);\n\n const event: TelemetryEvent = {\n type: \"request\",\n method: pending.method,\n argsBytes: pending.argsBytes,\n resultBytes: byteLength(error ?? result),\n durationMs: Date.now() - pending.startedAt,\n outcome: error ? \"error\" : \"ok\",\n errorCode: error?.code,\n timestamp: new Date().toISOString(),\n ...pending.extra,\n };\n\n if (pending.method === \"initialize\" && result?.serverInfo) {\n event.serverName = result.serverInfo.name;\n event.serverVersion = result.serverInfo.version;\n }\n\n return event;\n }\n}\n","import type { TelemetryEvent, SdkName, InstallMode } from \"../types.js\";\nimport type { ResolvedConfig } from \"../config.js\";\n\nexport interface BatchMeta {\n serverName?: string;\n serverVersion?: string;\n sdkName: SdkName;\n sdkVersion?: string;\n installMode: InstallMode;\n}\n\n/**\n * Ring-buffer batcher: queues events, flushes on a timer or when full, and never\n * throws or retries indefinitely — a failed or unreachable ingest endpoint is a\n * silent no-op, by design (telemetry must never affect the server's own behavior,\n * and the endpoint may not exist yet — see telemetry-master-plan.md §4/§7).\n */\nexport class TelemetryBatcher {\n private queue: TelemetryEvent[] = [];\n private timer: ReturnType<typeof setInterval>;\n\n constructor(\n private readonly config: ResolvedConfig,\n private readonly meta: BatchMeta\n ) {\n this.timer = setInterval(() => void this.flush(), config.flushIntervalMs);\n this.timer.unref?.(); // telemetry must never be the reason a process stays alive\n }\n\n push(event: TelemetryEvent): void {\n this.queue.push(event);\n if (this.queue.length >= this.config.maxBatchSize) void this.flush();\n }\n\n async flush(): Promise<void> {\n if (this.queue.length === 0) return;\n const events = this.queue.splice(0, this.queue.length);\n try {\n await fetch(this.config.endpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.config.apiKey ?? \"\"}`,\n },\n body: JSON.stringify({\n serverName: this.meta.serverName,\n serverVersion: this.meta.serverVersion,\n sdkName: this.meta.sdkName,\n sdkVersion: this.meta.sdkVersion,\n installMode: this.meta.installMode,\n events,\n }),\n });\n } catch {\n // silent drop\n }\n }\n\n async close(): Promise<void> {\n clearInterval(this.timer);\n await this.flush();\n }\n}\n","import { resolveConfig } from \"./config.js\";\nimport { MessageClassifier } from \"./core/classify.js\";\nimport { TelemetryBatcher } from \"./core/batcher.js\";\nimport type { MinimalTransport, TelemetryOptions } from \"./types.js\";\n\n/**\n * Wraps a Transport's `onmessage`/`send` seam to capture telemetry, then delegates\n * everything through to the real transport unchanged. If no API key is configured\n * (via `options.apiKey` or the MCPFY_API_KEY env var), this is a complete no-op —\n * the original transport is returned untouched.\n *\n * Usage (add these lines yourself — nothing here edits your files for you):\n *\n * import { withMcpfyTelemetry } from \"mcpfy-pulse\";\n * const transport = new StdioServerTransport();\n * await server.connect(withMcpfyTelemetry(transport, { apiKey: process.env.MCPFY_API_KEY }));\n */\nexport function withMcpfyTelemetry<T extends MinimalTransport>(transport: T, options?: TelemetryOptions): T {\n const config = resolveConfig(options);\n if (!config.apiKey) return transport;\n\n const classifier = new MessageClassifier();\n const batcher = new TelemetryBatcher(config, {\n serverName: options?.serverName,\n serverVersion: options?.serverVersion,\n sdkName: options?.sdkName ?? \"@modelcontextprotocol/sdk\",\n sdkVersion: options?.sdkVersion,\n installMode: options?.installMode ?? \"sdk-wrapper\",\n });\n\n const wrapped: MinimalTransport = {\n start: () => transport.start(),\n close: () => {\n void batcher.close();\n return transport.close();\n },\n send: (message: any, sendOptions?: any) => {\n const event = classifier.onOutgoing(message);\n if (event) batcher.push(event);\n return transport.send(message, sendOptions);\n },\n get sessionId() {\n return transport.sessionId;\n },\n get onclose() {\n return transport.onclose;\n },\n set onclose(fn) {\n transport.onclose = fn;\n },\n get onerror() {\n return transport.onerror;\n },\n set onerror(fn) {\n transport.onerror = fn;\n },\n get onmessage() {\n return transport.onmessage;\n },\n set onmessage(handler) {\n transport.onmessage = (message: any, extra?: any) => {\n classifier.onIncoming(message);\n handler?.(message, extra);\n };\n },\n };\n\n return wrapped as T;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,3 @@
1
+ export { withMcpfyTelemetry } from "./wrap-transport.js";
2
+ export type { TelemetryOptions, TelemetryEvent, MinimalTransport, SdkName, InstallMode } from "./types.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,gBAAgB,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,177 @@
1
+ // src/config.ts
2
+ var DEFAULT_ENDPOINT = "https://api.mcpfy.ai/v1/telemetry/ingest";
3
+ var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
4
+ var DEFAULT_MAX_BATCH_SIZE = 500;
5
+ function resolveConfig(options) {
6
+ return {
7
+ apiKey: options?.apiKey ?? process.env.MCPFY_API_KEY,
8
+ endpoint: options?.endpoint ?? process.env.MCPFY_TELEMETRY_ENDPOINT ?? DEFAULT_ENDPOINT,
9
+ flushIntervalMs: options?.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
10
+ maxBatchSize: options?.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE
11
+ };
12
+ }
13
+
14
+ // src/core/classify.ts
15
+ function byteLength(value) {
16
+ if (value === void 0) return 0;
17
+ try {
18
+ return Buffer.byteLength(JSON.stringify(value));
19
+ } catch {
20
+ return 0;
21
+ }
22
+ }
23
+ function extractLabel(method, params) {
24
+ switch (method) {
25
+ case "tools/call":
26
+ return { toolName: params?.name };
27
+ case "prompts/get":
28
+ return { promptName: params?.name };
29
+ case "resources/read":
30
+ return { resourceUri: params?.uri };
31
+ case "initialize":
32
+ return {
33
+ clientName: params?.clientInfo?.name,
34
+ clientVersion: params?.clientInfo?.version,
35
+ protocolVersion: params?.protocolVersion
36
+ };
37
+ default:
38
+ return {};
39
+ }
40
+ }
41
+ var MessageClassifier = class {
42
+ pending = /* @__PURE__ */ new Map();
43
+ onIncoming(message) {
44
+ if (!message || typeof message !== "object") return;
45
+ const { id, method, params } = message;
46
+ if (id === void 0 || !method) return;
47
+ this.pending.set(id, {
48
+ method,
49
+ startedAt: Date.now(),
50
+ argsBytes: byteLength(params),
51
+ extra: extractLabel(method, params)
52
+ });
53
+ }
54
+ onOutgoing(message) {
55
+ if (!message || typeof message !== "object") return void 0;
56
+ const { id, method, result, error } = message;
57
+ if (method || id === void 0) return void 0;
58
+ const pending = this.pending.get(id);
59
+ if (!pending) return void 0;
60
+ this.pending.delete(id);
61
+ const event = {
62
+ type: "request",
63
+ method: pending.method,
64
+ argsBytes: pending.argsBytes,
65
+ resultBytes: byteLength(error ?? result),
66
+ durationMs: Date.now() - pending.startedAt,
67
+ outcome: error ? "error" : "ok",
68
+ errorCode: error?.code,
69
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
70
+ ...pending.extra
71
+ };
72
+ if (pending.method === "initialize" && result?.serverInfo) {
73
+ event.serverName = result.serverInfo.name;
74
+ event.serverVersion = result.serverInfo.version;
75
+ }
76
+ return event;
77
+ }
78
+ };
79
+
80
+ // src/core/batcher.ts
81
+ var TelemetryBatcher = class {
82
+ constructor(config, meta) {
83
+ this.config = config;
84
+ this.meta = meta;
85
+ this.timer = setInterval(() => void this.flush(), config.flushIntervalMs);
86
+ this.timer.unref?.();
87
+ }
88
+ config;
89
+ meta;
90
+ queue = [];
91
+ timer;
92
+ push(event) {
93
+ this.queue.push(event);
94
+ if (this.queue.length >= this.config.maxBatchSize) void this.flush();
95
+ }
96
+ async flush() {
97
+ if (this.queue.length === 0) return;
98
+ const events = this.queue.splice(0, this.queue.length);
99
+ try {
100
+ await fetch(this.config.endpoint, {
101
+ method: "POST",
102
+ headers: {
103
+ "content-type": "application/json",
104
+ authorization: `Bearer ${this.config.apiKey ?? ""}`
105
+ },
106
+ body: JSON.stringify({
107
+ serverName: this.meta.serverName,
108
+ serverVersion: this.meta.serverVersion,
109
+ sdkName: this.meta.sdkName,
110
+ sdkVersion: this.meta.sdkVersion,
111
+ installMode: this.meta.installMode,
112
+ events
113
+ })
114
+ });
115
+ } catch {
116
+ }
117
+ }
118
+ async close() {
119
+ clearInterval(this.timer);
120
+ await this.flush();
121
+ }
122
+ };
123
+
124
+ // src/wrap-transport.ts
125
+ function withMcpfyTelemetry(transport, options) {
126
+ const config = resolveConfig(options);
127
+ if (!config.apiKey) return transport;
128
+ const classifier = new MessageClassifier();
129
+ const batcher = new TelemetryBatcher(config, {
130
+ serverName: options?.serverName,
131
+ serverVersion: options?.serverVersion,
132
+ sdkName: options?.sdkName ?? "@modelcontextprotocol/sdk",
133
+ sdkVersion: options?.sdkVersion,
134
+ installMode: options?.installMode ?? "sdk-wrapper"
135
+ });
136
+ const wrapped = {
137
+ start: () => transport.start(),
138
+ close: () => {
139
+ void batcher.close();
140
+ return transport.close();
141
+ },
142
+ send: (message, sendOptions) => {
143
+ const event = classifier.onOutgoing(message);
144
+ if (event) batcher.push(event);
145
+ return transport.send(message, sendOptions);
146
+ },
147
+ get sessionId() {
148
+ return transport.sessionId;
149
+ },
150
+ get onclose() {
151
+ return transport.onclose;
152
+ },
153
+ set onclose(fn) {
154
+ transport.onclose = fn;
155
+ },
156
+ get onerror() {
157
+ return transport.onerror;
158
+ },
159
+ set onerror(fn) {
160
+ transport.onerror = fn;
161
+ },
162
+ get onmessage() {
163
+ return transport.onmessage;
164
+ },
165
+ set onmessage(handler) {
166
+ transport.onmessage = (message, extra) => {
167
+ classifier.onIncoming(message);
168
+ handler?.(message, extra);
169
+ };
170
+ }
171
+ };
172
+ return wrapped;
173
+ }
174
+ export {
175
+ withMcpfyTelemetry
176
+ };
177
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/config.ts","../../src/core/classify.ts","../../src/core/batcher.ts","../../src/wrap-transport.ts"],"sourcesContent":["import type { TelemetryOptions } from \"./types.js\";\n\n// Path matches the real route on cloudmcp-nest: POST /v1/telemetry/ingest.\nconst DEFAULT_ENDPOINT = \"https://api.mcpfy.ai/v1/telemetry/ingest\";\nconst DEFAULT_FLUSH_INTERVAL_MS = 5000;\nconst DEFAULT_MAX_BATCH_SIZE = 500;\n\nexport interface ResolvedConfig {\n apiKey: string | undefined;\n endpoint: string;\n flushIntervalMs: number;\n maxBatchSize: number;\n}\n\nexport function resolveConfig(options?: TelemetryOptions): ResolvedConfig {\n return {\n apiKey: options?.apiKey ?? process.env.MCPFY_API_KEY,\n endpoint: options?.endpoint ?? process.env.MCPFY_TELEMETRY_ENDPOINT ?? DEFAULT_ENDPOINT,\n flushIntervalMs: options?.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,\n maxBatchSize: options?.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE,\n };\n}\n","import type { TelemetryEvent } from \"../types.js\";\n\ninterface PendingEntry {\n method: string;\n startedAt: number;\n argsBytes: number;\n extra: Partial<TelemetryEvent>;\n}\n\nfunction byteLength(value: unknown): number {\n if (value === undefined) return 0;\n try {\n return Buffer.byteLength(JSON.stringify(value));\n } catch {\n return 0;\n }\n}\n\nfunction extractLabel(method: string, params: any): Partial<TelemetryEvent> {\n switch (method) {\n case \"tools/call\":\n return { toolName: params?.name };\n case \"prompts/get\":\n return { promptName: params?.name };\n case \"resources/read\":\n return { resourceUri: params?.uri };\n case \"initialize\":\n // Protocol handshake metadata, not user data — same fields the spec itself exchanges in the clear.\n return {\n clientName: params?.clientInfo?.name,\n clientVersion: params?.clientInfo?.version,\n protocolVersion: params?.protocolVersion,\n };\n default:\n return {};\n }\n}\n\n/**\n * Tracks request/response pairs across the two seams every Transport exposes\n * (onmessage = incoming, send = outgoing) and emits one event per completed\n * request. Only method names, byte counts, timing, and outcome are captured —\n * argument values and result content are never read beyond their byte length.\n *\n * Server-initiated requests/notifications (sampling, elicitation, logging,\n * progress) are intentionally not tracked yet — out of scope for this pass.\n */\nexport class MessageClassifier {\n private pending = new Map<string | number, PendingEntry>();\n\n onIncoming(message: any): void {\n if (!message || typeof message !== \"object\") return;\n const { id, method, params } = message;\n if (id === undefined || !method) return; // only requests carry both an id and a method\n this.pending.set(id, {\n method,\n startedAt: Date.now(),\n argsBytes: byteLength(params),\n extra: extractLabel(method, params),\n });\n }\n\n onOutgoing(message: any): TelemetryEvent | undefined {\n if (!message || typeof message !== \"object\") return undefined;\n const { id, method, result, error } = message;\n if (method || id === undefined) return undefined;\n\n const pending = this.pending.get(id);\n if (!pending) return undefined;\n this.pending.delete(id);\n\n const event: TelemetryEvent = {\n type: \"request\",\n method: pending.method,\n argsBytes: pending.argsBytes,\n resultBytes: byteLength(error ?? result),\n durationMs: Date.now() - pending.startedAt,\n outcome: error ? \"error\" : \"ok\",\n errorCode: error?.code,\n timestamp: new Date().toISOString(),\n ...pending.extra,\n };\n\n if (pending.method === \"initialize\" && result?.serverInfo) {\n event.serverName = result.serverInfo.name;\n event.serverVersion = result.serverInfo.version;\n }\n\n return event;\n }\n}\n","import type { TelemetryEvent, SdkName, InstallMode } from \"../types.js\";\nimport type { ResolvedConfig } from \"../config.js\";\n\nexport interface BatchMeta {\n serverName?: string;\n serverVersion?: string;\n sdkName: SdkName;\n sdkVersion?: string;\n installMode: InstallMode;\n}\n\n/**\n * Ring-buffer batcher: queues events, flushes on a timer or when full, and never\n * throws or retries indefinitely — a failed or unreachable ingest endpoint is a\n * silent no-op, by design (telemetry must never affect the server's own behavior,\n * and the endpoint may not exist yet — see telemetry-master-plan.md §4/§7).\n */\nexport class TelemetryBatcher {\n private queue: TelemetryEvent[] = [];\n private timer: ReturnType<typeof setInterval>;\n\n constructor(\n private readonly config: ResolvedConfig,\n private readonly meta: BatchMeta\n ) {\n this.timer = setInterval(() => void this.flush(), config.flushIntervalMs);\n this.timer.unref?.(); // telemetry must never be the reason a process stays alive\n }\n\n push(event: TelemetryEvent): void {\n this.queue.push(event);\n if (this.queue.length >= this.config.maxBatchSize) void this.flush();\n }\n\n async flush(): Promise<void> {\n if (this.queue.length === 0) return;\n const events = this.queue.splice(0, this.queue.length);\n try {\n await fetch(this.config.endpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${this.config.apiKey ?? \"\"}`,\n },\n body: JSON.stringify({\n serverName: this.meta.serverName,\n serverVersion: this.meta.serverVersion,\n sdkName: this.meta.sdkName,\n sdkVersion: this.meta.sdkVersion,\n installMode: this.meta.installMode,\n events,\n }),\n });\n } catch {\n // silent drop\n }\n }\n\n async close(): Promise<void> {\n clearInterval(this.timer);\n await this.flush();\n }\n}\n","import { resolveConfig } from \"./config.js\";\nimport { MessageClassifier } from \"./core/classify.js\";\nimport { TelemetryBatcher } from \"./core/batcher.js\";\nimport type { MinimalTransport, TelemetryOptions } from \"./types.js\";\n\n/**\n * Wraps a Transport's `onmessage`/`send` seam to capture telemetry, then delegates\n * everything through to the real transport unchanged. If no API key is configured\n * (via `options.apiKey` or the MCPFY_API_KEY env var), this is a complete no-op —\n * the original transport is returned untouched.\n *\n * Usage (add these lines yourself — nothing here edits your files for you):\n *\n * import { withMcpfyTelemetry } from \"mcpfy-pulse\";\n * const transport = new StdioServerTransport();\n * await server.connect(withMcpfyTelemetry(transport, { apiKey: process.env.MCPFY_API_KEY }));\n */\nexport function withMcpfyTelemetry<T extends MinimalTransport>(transport: T, options?: TelemetryOptions): T {\n const config = resolveConfig(options);\n if (!config.apiKey) return transport;\n\n const classifier = new MessageClassifier();\n const batcher = new TelemetryBatcher(config, {\n serverName: options?.serverName,\n serverVersion: options?.serverVersion,\n sdkName: options?.sdkName ?? \"@modelcontextprotocol/sdk\",\n sdkVersion: options?.sdkVersion,\n installMode: options?.installMode ?? \"sdk-wrapper\",\n });\n\n const wrapped: MinimalTransport = {\n start: () => transport.start(),\n close: () => {\n void batcher.close();\n return transport.close();\n },\n send: (message: any, sendOptions?: any) => {\n const event = classifier.onOutgoing(message);\n if (event) batcher.push(event);\n return transport.send(message, sendOptions);\n },\n get sessionId() {\n return transport.sessionId;\n },\n get onclose() {\n return transport.onclose;\n },\n set onclose(fn) {\n transport.onclose = fn;\n },\n get onerror() {\n return transport.onerror;\n },\n set onerror(fn) {\n transport.onerror = fn;\n },\n get onmessage() {\n return transport.onmessage;\n },\n set onmessage(handler) {\n transport.onmessage = (message: any, extra?: any) => {\n classifier.onIncoming(message);\n handler?.(message, extra);\n };\n },\n };\n\n return wrapped as T;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `mcpfy-proxy -- <command> [args...]`
3
+ *
4
+ * Spawns <command> as a child process and sits in its stdin/stdout pipe. Client-to-server
5
+ * messages (parent stdin -> child stdin) are classified as "incoming"; server-to-client
6
+ * messages (child stdout -> parent stdout) are classified as "outgoing", mirroring
7
+ * onmessage/send in the in-process wrapper. Works for any language — the proxy never
8
+ * parses anything beyond newline-delimited JSON-RPC framing.
9
+ */
10
+ export declare function runProxy(argv: string[]): Promise<void>;
11
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../../src/proxy/run.ts"],"names":[],"mappings":"AAmCA;;;;;;;;GAQG;AACH,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5D"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Structural stand-in for the MCP SDK's `Transport` interface. Kept local (rather than
3
+ * depending on @modelcontextprotocol/sdk) so this package stays dependency-free — any
4
+ * real Transport (stdio, StreamableHTTP, or a third-party SDK's own transport) already
5
+ * satisfies this shape.
6
+ */
7
+ export interface MinimalTransport {
8
+ start(): Promise<void>;
9
+ close(): Promise<void>;
10
+ send(message: any, options?: any): Promise<void>;
11
+ onmessage?: (message: any, extra?: any) => void;
12
+ onclose?: () => void;
13
+ onerror?: (error: Error) => void;
14
+ sessionId?: string;
15
+ }
16
+ export type SdkName = "mcpfy-sdk" | "@modelcontextprotocol/sdk" | "unknown";
17
+ export type InstallMode = "sdk-env" | "sdk-wrapper" | "stdio-proxy";
18
+ export interface TelemetryOptions {
19
+ /** Defaults to the MCPFY_API_KEY env var. Unset (in either form) = no-op, nothing is sent. */
20
+ apiKey?: string;
21
+ /** Defaults to MCPFY_TELEMETRY_ENDPOINT env var, then the MCPFY ingest URL. */
22
+ endpoint?: string;
23
+ serverName?: string;
24
+ serverVersion?: string;
25
+ sdkName?: SdkName;
26
+ sdkVersion?: string;
27
+ installMode?: InstallMode;
28
+ flushIntervalMs?: number;
29
+ maxBatchSize?: number;
30
+ }
31
+ /**
32
+ * What actually crosses the wire. Method names, byte counts, timing, and outcome only —
33
+ * never argument values or result content. See telemetry-master-plan.md §2-3.
34
+ */
35
+ export interface TelemetryEvent {
36
+ type: "request";
37
+ method: string;
38
+ toolName?: string;
39
+ promptName?: string;
40
+ resourceUri?: string;
41
+ clientName?: string;
42
+ clientVersion?: string;
43
+ protocolVersion?: string;
44
+ serverName?: string;
45
+ serverVersion?: string;
46
+ argsBytes?: number;
47
+ resultBytes?: number;
48
+ durationMs?: number;
49
+ outcome?: "ok" | "error";
50
+ errorCode?: number;
51
+ timestamp: string;
52
+ }
53
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjD,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,KAAK,CAAC,EAAE,GAAG,KAAK,IAAI,CAAC;IAChD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,OAAO,GAAG,WAAW,GAAG,2BAA2B,GAAG,SAAS,CAAC;AAC5E,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,aAAa,GAAG,aAAa,CAAC;AAEpE,MAAM,WAAW,gBAAgB;IAC/B,8FAA8F;IAC9F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,IAAI,GAAG,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB"}
@@ -0,0 +1,15 @@
1
+ import type { MinimalTransport, TelemetryOptions } from "./types.js";
2
+ /**
3
+ * Wraps a Transport's `onmessage`/`send` seam to capture telemetry, then delegates
4
+ * everything through to the real transport unchanged. If no API key is configured
5
+ * (via `options.apiKey` or the MCPFY_API_KEY env var), this is a complete no-op —
6
+ * the original transport is returned untouched.
7
+ *
8
+ * Usage (add these lines yourself — nothing here edits your files for you):
9
+ *
10
+ * import { withMcpfyTelemetry } from "mcpfy-pulse";
11
+ * const transport = new StdioServerTransport();
12
+ * await server.connect(withMcpfyTelemetry(transport, { apiKey: process.env.MCPFY_API_KEY }));
13
+ */
14
+ export declare function withMcpfyTelemetry<T extends MinimalTransport>(transport: T, options?: TelemetryOptions): T;
15
+ //# sourceMappingURL=wrap-transport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wrap-transport.d.ts","sourceRoot":"","sources":["../../src/wrap-transport.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAErE;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,gBAAgB,EAAE,SAAS,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,CAAC,CAmD1G"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "mcpfy-pulse",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "description": "Telemetry for MCP servers: wrap any Transport, or proxy any command over stdio, to capture method/size/duration/outcome metrics for the MCPFY dashboard. Never sends argument values or resource content.",
6
+ "author": "mcpfy",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/mcpfyy/mcpfy#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/mcpfyy/mcpfy.git",
12
+ "directory": "typescript/packages/mcpfy-pulse"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/mcpfyy/mcpfy/issues"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "mcpfy",
20
+ "model-context-protocol",
21
+ "telemetry",
22
+ "observability",
23
+ "proxy"
24
+ ],
25
+ "engines": {
26
+ "node": "^20.19.0 || >=22.12.0"
27
+ },
28
+ "bin": {
29
+ "mcpfy-proxy": "dist/bin/mcpfy-proxy.js"
30
+ },
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/src/index.d.ts",
34
+ "import": "./dist/src/index.js",
35
+ "require": "./dist/src/index.cjs"
36
+ }
37
+ },
38
+ "main": "./dist/src/index.js",
39
+ "module": "./dist/src/index.js",
40
+ "types": "./dist/src/index.d.ts",
41
+ "files": [
42
+ "dist",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.10.2",
51
+ "rimraf": "^6.0.1",
52
+ "tsup": "^8.3.5",
53
+ "tsx": "^4.19.2",
54
+ "typescript": "^5.6.3",
55
+ "vitest": "^2.1.8"
56
+ },
57
+ "scripts": {
58
+ "build": "rimraf dist && tsup && tsc --emitDeclarationOnly --declaration",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest"
61
+ }
62
+ }