@mkterswingman/5mghost-yonder 0.0.39 → 0.0.40

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/server.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { YtMcpConfig } from "./utils/config.js";
3
3
  import type { TokenManager } from "@mkterswingman/5mghost-shared-client/auth";
4
4
  import { DownloadJobManager } from "./download/jobManager.js";
5
5
  import type { DownloadToolDeps } from "./tools/downloads.js";
6
+ import { type ToolTelemetryDeps } from "./telemetry.js";
6
7
  /**
7
8
  * Creates the MCP server.
8
9
  *
@@ -20,4 +21,4 @@ import type { DownloadToolDeps } from "./tools/downloads.js";
20
21
  * ├─ PAT login URL (user clicks to get token)
21
22
  * └─ setup command (for full OAuth + cookies)
22
23
  */
23
- export declare function createServer(config: YtMcpConfig, tokenManager: TokenManager, downloadJobManager?: DownloadJobManager, downloadToolDeps?: DownloadToolDeps): Promise<McpServer>;
24
+ export declare function createServer(config: YtMcpConfig, tokenManager: TokenManager, downloadJobManager?: DownloadJobManager, downloadToolDeps?: DownloadToolDeps, telemetryDeps?: ToolTelemetryDeps): Promise<McpServer>;
package/dist/server.js CHANGED
@@ -3,6 +3,8 @@ import { DownloadJobManager } from "./download/jobManager.js";
3
3
  import { registerSubtitleTools } from "./tools/subtitles.js";
4
4
  import { registerDownloadTools } from "./tools/downloads.js";
5
5
  import { registerRemoteTools } from "./tools/remote.js";
6
+ import { ToolTelemetryClient } from "./telemetry.js";
7
+ import { buildBrowserOpenCommand } from "./utils/browserLaunch.js";
6
8
  /**
7
9
  * Creates the MCP server.
8
10
  *
@@ -20,7 +22,7 @@ import { registerRemoteTools } from "./tools/remote.js";
20
22
  * ├─ PAT login URL (user clicks to get token)
21
23
  * └─ setup command (for full OAuth + cookies)
22
24
  */
23
- export async function createServer(config, tokenManager, downloadJobManager = new DownloadJobManager(), downloadToolDeps = {}) {
25
+ export async function createServer(config, tokenManager, downloadJobManager = new DownloadJobManager(), downloadToolDeps = {}, telemetryDeps = {}) {
24
26
  const server = new McpServer({
25
27
  name: "@mkterswingman/yt-mcp",
26
28
  version: "0.1.0",
@@ -73,9 +75,29 @@ export async function createServer(config, tokenManager, downloadJobManager = ne
73
75
  return server;
74
76
  }
75
77
  // Authenticated — register all tools
76
- registerSubtitleTools(server, config, tokenManager);
77
- registerDownloadTools(server, config, tokenManager, downloadJobManager, downloadToolDeps);
78
- registerRemoteTools(server, config, tokenManager);
78
+ const telemetry = new ToolTelemetryClient(config, tokenManager, telemetryDeps);
79
+ const instrumentedServer = withToolTelemetry(server, telemetry);
80
+ registerSubtitleTools(instrumentedServer, config, tokenManager);
81
+ registerDownloadTools(instrumentedServer, config, tokenManager, downloadJobManager, downloadToolDeps);
82
+ registerRemoteTools(instrumentedServer, config, tokenManager);
79
83
  return server;
80
84
  }
81
- import { buildBrowserOpenCommand } from "./utils/browserLaunch.js";
85
+ function withToolTelemetry(server, telemetry) {
86
+ const originalRegisterTool = server.registerTool.bind(server);
87
+ const instrumented = Object.create(server);
88
+ instrumented.registerTool = ((name, config, handler) => {
89
+ return originalRegisterTool(name, config, async (...args) => {
90
+ const startedAtMs = Date.now();
91
+ try {
92
+ const result = await handler(...args);
93
+ telemetry.recordToolCall({ toolName: name, startedAtMs, result: result });
94
+ return result;
95
+ }
96
+ catch (error) {
97
+ telemetry.recordToolCall({ toolName: name, startedAtMs, thrown: error });
98
+ throw error;
99
+ }
100
+ });
101
+ });
102
+ return instrumented;
103
+ }
@@ -0,0 +1,64 @@
1
+ import type { TokenManager } from "@mkterswingman/5mghost-shared-client/auth";
2
+ import type { YtMcpConfig } from "./utils/config.js";
3
+ type ToolOutcome = "success" | "failure";
4
+ type ErrorKind = "validation" | "auth" | "upstream" | "rate_limit" | "internal";
5
+ export interface ToolTelemetryEvent {
6
+ schema_version: 1;
7
+ event_id: string;
8
+ occurred_at: string;
9
+ product: string;
10
+ product_version: string;
11
+ install_id: string;
12
+ event_type: "tool_call";
13
+ tool_name: string;
14
+ outcome: ToolOutcome;
15
+ duration_ms: number;
16
+ error_kind?: ErrorKind;
17
+ error_code?: string;
18
+ error_message?: string;
19
+ }
20
+ interface ToolResultLike {
21
+ isError?: boolean;
22
+ structuredContent?: unknown;
23
+ }
24
+ interface FetchLike {
25
+ (input: string | URL, init?: RequestInit): Promise<Response>;
26
+ }
27
+ interface TelemetryLogger {
28
+ warn(event: string, data?: Record<string, unknown>): void;
29
+ }
30
+ export interface ToolTelemetryDeps {
31
+ fetchImpl?: FetchLike;
32
+ installId?: string;
33
+ logger?: TelemetryLogger;
34
+ }
35
+ export declare class ToolTelemetryClient {
36
+ private readonly config;
37
+ private readonly tokenManager;
38
+ private readonly fetchImpl;
39
+ private readonly configuredInstallId;
40
+ private persistedInstallId;
41
+ private readonly logger;
42
+ constructor(config: Pick<YtMcpConfig, "auth_url" | "telemetry_enabled" | "telemetry_endpoint_url" | "telemetry_timeout_ms">, tokenManager: Pick<TokenManager, "getValidToken">, deps?: ToolTelemetryDeps);
43
+ recordToolCall(input: {
44
+ toolName: string;
45
+ startedAtMs: number;
46
+ result?: ToolResultLike;
47
+ thrown?: unknown;
48
+ }): void;
49
+ private getInstallId;
50
+ private send;
51
+ }
52
+ export declare function buildToolTelemetryEvent(input: {
53
+ toolName: string;
54
+ startedAtMs: number;
55
+ installId: string;
56
+ result?: ToolResultLike;
57
+ thrown?: unknown;
58
+ }): ToolTelemetryEvent;
59
+ export declare function classifyTelemetryError(result?: ToolResultLike, thrown?: unknown): {
60
+ kind: ErrorKind;
61
+ code: string;
62
+ message: string;
63
+ } | null;
64
+ export {};
@@ -0,0 +1,225 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { PATHS } from "./utils/config.js";
5
+ const PRODUCT = "5mghost-yonder";
6
+ const PRODUCT_VERSION = "0.0.40";
7
+ const MAX_ERROR_CODE_CHARS = 64;
8
+ export class ToolTelemetryClient {
9
+ config;
10
+ tokenManager;
11
+ fetchImpl;
12
+ configuredInstallId;
13
+ persistedInstallId;
14
+ logger;
15
+ constructor(config, tokenManager, deps = {}) {
16
+ this.config = config;
17
+ this.tokenManager = tokenManager;
18
+ this.fetchImpl = deps.fetchImpl ?? fetch;
19
+ this.configuredInstallId = deps.installId;
20
+ this.logger = deps.logger ?? { warn: () => { } };
21
+ }
22
+ recordToolCall(input) {
23
+ if (!this.config.telemetry_enabled) {
24
+ return;
25
+ }
26
+ const event = buildToolTelemetryEvent({
27
+ toolName: input.toolName,
28
+ startedAtMs: input.startedAtMs,
29
+ installId: this.getInstallId(),
30
+ result: input.result,
31
+ thrown: input.thrown,
32
+ });
33
+ setImmediate(() => {
34
+ void this.send(event);
35
+ });
36
+ }
37
+ getInstallId() {
38
+ if (this.configuredInstallId) {
39
+ return this.configuredInstallId;
40
+ }
41
+ this.persistedInstallId ??= getOrCreateInstallId();
42
+ return this.persistedInstallId;
43
+ }
44
+ async send(event) {
45
+ if (!isSameOrigin(this.config.telemetry_endpoint_url, this.config.auth_url)) {
46
+ this.logger.warn("yt_mcp_telemetry_same_origin_blocked", {
47
+ product: PRODUCT,
48
+ tool_name: event.tool_name,
49
+ });
50
+ return;
51
+ }
52
+ try {
53
+ const token = await this.tokenManager.getValidToken();
54
+ if (!token) {
55
+ return;
56
+ }
57
+ const controller = new AbortController();
58
+ const timeout = setTimeout(() => controller.abort(), this.config.telemetry_timeout_ms);
59
+ timeout.unref?.();
60
+ try {
61
+ const response = await this.fetchImpl(this.config.telemetry_endpoint_url, {
62
+ method: "POST",
63
+ headers: {
64
+ Authorization: `Bearer ${token}`,
65
+ "Content-Type": "application/json",
66
+ },
67
+ body: JSON.stringify({ events: [event] }),
68
+ signal: controller.signal,
69
+ });
70
+ if (!response.ok) {
71
+ this.logger.warn("yt_mcp_telemetry_send_failed", {
72
+ product: PRODUCT,
73
+ tool_name: event.tool_name,
74
+ status_code: response.status,
75
+ });
76
+ }
77
+ }
78
+ finally {
79
+ clearTimeout(timeout);
80
+ }
81
+ }
82
+ catch (error) {
83
+ this.logger.warn("yt_mcp_telemetry_send_failed", {
84
+ product: PRODUCT,
85
+ tool_name: event.tool_name,
86
+ code: "TELEMETRY_NETWORK_ERROR",
87
+ message: error instanceof Error ? error.name : "UnknownError",
88
+ });
89
+ }
90
+ }
91
+ }
92
+ export function buildToolTelemetryEvent(input) {
93
+ const durationMs = Math.max(0, Math.round(Date.now() - input.startedAtMs));
94
+ const error = classifyTelemetryError(input.result, input.thrown);
95
+ const base = {
96
+ schema_version: 1,
97
+ event_id: `${PRODUCT}-${Date.now()}-${randomUUID()}`,
98
+ occurred_at: new Date().toISOString(),
99
+ product: PRODUCT,
100
+ product_version: PRODUCT_VERSION,
101
+ install_id: input.installId,
102
+ event_type: "tool_call",
103
+ tool_name: input.toolName,
104
+ duration_ms: durationMs,
105
+ };
106
+ if (!error) {
107
+ return {
108
+ ...base,
109
+ outcome: "success",
110
+ };
111
+ }
112
+ return {
113
+ ...base,
114
+ outcome: "failure",
115
+ error_kind: error.kind,
116
+ error_code: error.code,
117
+ error_message: error.message,
118
+ };
119
+ }
120
+ export function classifyTelemetryError(result, thrown) {
121
+ if (thrown) {
122
+ return {
123
+ kind: "internal",
124
+ code: "INTERNAL_ERROR",
125
+ message: "Tool handler threw an unexpected error",
126
+ };
127
+ }
128
+ if (!result?.isError) {
129
+ return null;
130
+ }
131
+ const error = readStructuredError(result.structuredContent);
132
+ const code = normalizeErrorCode(error.code);
133
+ return {
134
+ kind: classifyErrorKind(code),
135
+ code,
136
+ message: safeErrorMessage(code),
137
+ };
138
+ }
139
+ function readStructuredError(value) {
140
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
141
+ return { code: null };
142
+ }
143
+ const error = value.error;
144
+ if (!error || typeof error !== "object" || Array.isArray(error)) {
145
+ return { code: null };
146
+ }
147
+ const record = error;
148
+ return {
149
+ code: typeof record.code === "string" ? record.code : null,
150
+ };
151
+ }
152
+ function normalizeErrorCode(code) {
153
+ if (!code) {
154
+ return "UNKNOWN_ERROR";
155
+ }
156
+ const normalized = code.toUpperCase().replace(/[^A-Z0-9_.:-]/g, "_").slice(0, MAX_ERROR_CODE_CHARS);
157
+ return normalized || "UNKNOWN_ERROR";
158
+ }
159
+ function classifyErrorKind(code) {
160
+ if (code.includes("RATE_LIMIT") || code === "HTTP_429") {
161
+ return "rate_limit";
162
+ }
163
+ if (code.startsWith("AUTH") || code.includes("COOKIE") || code === "SIGN_IN_REQUIRED") {
164
+ return "auth";
165
+ }
166
+ if (code === "INVALID_INPUT" || code.includes("VALIDATION")) {
167
+ return "validation";
168
+ }
169
+ if (code.startsWith("REMOTE") || code.startsWith("UPSTREAM") || code.startsWith("HTTP_") || code === "NETWORK_ERROR") {
170
+ return "upstream";
171
+ }
172
+ return "internal";
173
+ }
174
+ function safeErrorMessage(code) {
175
+ const kind = classifyErrorKind(code);
176
+ if (kind === "rate_limit") {
177
+ return "Rate limit was reached";
178
+ }
179
+ if (kind === "auth") {
180
+ return "Authorization failed";
181
+ }
182
+ if (kind === "validation") {
183
+ return "Invalid input";
184
+ }
185
+ if (kind === "upstream") {
186
+ return "Remote tool request failed";
187
+ }
188
+ if (code === "NO_SUBTITLES") {
189
+ return "Requested subtitles were not found";
190
+ }
191
+ return "Tool call failed";
192
+ }
193
+ function isSameOrigin(endpoint, authUrl) {
194
+ try {
195
+ return new URL(endpoint).origin === new URL(authUrl).origin;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
201
+ function getOrCreateInstallId() {
202
+ const path = join(PATHS.configDir, "telemetry.json");
203
+ try {
204
+ if (existsSync(path)) {
205
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
206
+ if (typeof parsed.install_id === "string" && parsed.install_id.trim()) {
207
+ return parsed.install_id.trim();
208
+ }
209
+ }
210
+ }
211
+ catch {
212
+ // Why: telemetry must never block MCP tool registration on local state corruption.
213
+ }
214
+ const installId = `local-${randomUUID()}`;
215
+ try {
216
+ mkdirSync(PATHS.configDir, { recursive: true });
217
+ const tempPath = `${path}.tmp`;
218
+ writeFileSync(tempPath, JSON.stringify({ install_id: installId }, null, 2), { encoding: "utf8", mode: 0o600 });
219
+ renameSync(tempPath, path);
220
+ }
221
+ catch {
222
+ // Why: a read-only home directory should disable persistence, not MCP tools.
223
+ }
224
+ return installId;
225
+ }
@@ -47,6 +47,9 @@ export interface YtMcpConfig {
47
47
  batch_sleep_min_ms: number;
48
48
  batch_sleep_max_ms: number;
49
49
  batch_max_size: number;
50
+ telemetry_enabled: boolean;
51
+ telemetry_endpoint_url: string;
52
+ telemetry_timeout_ms: number;
50
53
  }
51
54
  export declare function ensureConfigDir(): void;
52
55
  export declare function loadConfig(): YtMcpConfig;
@@ -51,27 +51,87 @@ const DEFAULTS = {
51
51
  batch_sleep_min_ms: 3000,
52
52
  batch_sleep_max_ms: 8000,
53
53
  batch_max_size: 10,
54
+ telemetry_enabled: true,
55
+ telemetry_endpoint_url: "https://mkterswingman.com/telemetry/events",
56
+ telemetry_timeout_ms: 1500,
54
57
  };
55
58
  export function ensureConfigDir() {
56
59
  mkdirSync(PATHS.configDir, { recursive: true });
57
60
  }
58
61
  export function loadConfig() {
59
62
  ensureConfigDir();
63
+ let parsed = {};
60
64
  if (!existsSync(PATHS.configJson)) {
61
- return { ...DEFAULTS };
65
+ return normalizeConfig(parsed);
62
66
  }
63
67
  try {
64
68
  const raw = readFileSync(PATHS.configJson, "utf8");
65
- const parsed = JSON.parse(raw);
66
- return { ...DEFAULTS, ...parsed };
69
+ parsed = JSON.parse(raw);
67
70
  }
68
71
  catch {
69
- return { ...DEFAULTS };
72
+ parsed = {};
70
73
  }
74
+ return normalizeConfig(parsed);
71
75
  }
72
76
  export function saveConfig(config) {
73
77
  ensureConfigDir();
74
78
  const existing = loadConfig();
75
- const merged = { ...existing, ...config };
79
+ const merged = normalizeConfig({ ...existing, ...config });
76
80
  writeFileSync(PATHS.configJson, JSON.stringify(merged, null, 2), "utf8");
77
81
  }
82
+ function normalizeConfig(config) {
83
+ const authUrl = parseHttpUrl("auth_url", config.auth_url ?? DEFAULTS.auth_url);
84
+ const telemetryEndpointUrl = parseTelemetryEndpointUrl(authUrl, config.telemetry_endpoint_url);
85
+ return {
86
+ auth_url: authUrl,
87
+ api_url: parseHttpUrl("api_url", config.api_url ?? DEFAULTS.api_url),
88
+ default_languages: config.default_languages ?? DEFAULTS.default_languages,
89
+ batch_sleep_min_ms: config.batch_sleep_min_ms ?? DEFAULTS.batch_sleep_min_ms,
90
+ batch_sleep_max_ms: config.batch_sleep_max_ms ?? DEFAULTS.batch_sleep_max_ms,
91
+ batch_max_size: config.batch_max_size ?? DEFAULTS.batch_max_size,
92
+ telemetry_enabled: parseEnvBool("YT_MCP_TELEMETRY_ENABLED", config.telemetry_enabled ?? DEFAULTS.telemetry_enabled),
93
+ telemetry_endpoint_url: telemetryEndpointUrl,
94
+ telemetry_timeout_ms: parseEnvInt("YT_MCP_TELEMETRY_TIMEOUT_MS", config.telemetry_timeout_ms ?? DEFAULTS.telemetry_timeout_ms, 250, 10_000),
95
+ };
96
+ }
97
+ function parseHttpUrl(name, raw) {
98
+ let url;
99
+ try {
100
+ url = new URL(raw);
101
+ }
102
+ catch {
103
+ throw new Error(`${name} must be an absolute URL`);
104
+ }
105
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
106
+ throw new Error(`${name} must start with http:// or https://`);
107
+ }
108
+ url.search = "";
109
+ url.hash = "";
110
+ return url.toString().replace(/\/$/, "");
111
+ }
112
+ function parseTelemetryEndpointUrl(authUrl, configured) {
113
+ const endpoint = parseHttpUrl("telemetry_endpoint_url", process.env.YT_MCP_TELEMETRY_ENDPOINT_URL?.trim() || configured || `${authUrl}/telemetry/events`);
114
+ if (new URL(endpoint).origin !== new URL(authUrl).origin) {
115
+ throw new Error("telemetry_endpoint_url origin must match auth_url origin");
116
+ }
117
+ return endpoint;
118
+ }
119
+ function parseEnvBool(name, fallback) {
120
+ const raw = process.env[name]?.trim().toLowerCase();
121
+ if (!raw)
122
+ return fallback;
123
+ if (["1", "true", "yes", "on"].includes(raw))
124
+ return true;
125
+ if (["0", "false", "no", "off"].includes(raw))
126
+ return false;
127
+ return fallback;
128
+ }
129
+ function parseEnvInt(name, fallback, min, max) {
130
+ const raw = process.env[name]?.trim();
131
+ if (!raw)
132
+ return fallback;
133
+ const value = Number.parseInt(raw, 10);
134
+ if (!Number.isFinite(value))
135
+ return fallback;
136
+ return Math.min(max, Math.max(min, value));
137
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mkterswingman/5mghost-yonder",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
4
4
  "description": "Internal MCP client with local data tools and remote API proxy",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "test": "npm run build && node --test tests/*.test.mjs",
15
15
  "test:unit": "npm test",
16
16
  "test:tool": "npm run build && node --test tests/smoke.test.mjs tests/subtitleTools.test.mjs tests/downloadTools.test.mjs",
17
+ "test:mcp": "npm run test:tool",
17
18
  "test:smoke": "npm run build && node dist/cli/index.js --help && node dist/cli/index.js doctor",
18
19
  "test:clean-install": "npm run build && node scripts/clean-install-smoke.mjs",
19
20
  "prepublishOnly": "npm run build",