@mkterswingman/5mghost-yonder 0.0.39 → 0.0.41
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/cli/setup.js +5 -5
- package/dist/server.d.ts +2 -1
- package/dist/server.js +27 -5
- package/dist/telemetry.d.ts +64 -0
- package/dist/telemetry.js +225 -0
- package/dist/utils/config.d.ts +3 -0
- package/dist/utils/config.js +65 -5
- package/dist/utils/launcher.d.ts +11 -4
- package/dist/utils/launcher.js +109 -11
- package/package.json +2 -1
package/dist/cli/setup.js
CHANGED
|
@@ -443,8 +443,8 @@ export async function runSetup() {
|
|
|
443
443
|
{
|
|
444
444
|
"mcpServers": {
|
|
445
445
|
"yt-mcp": {
|
|
446
|
-
"command":
|
|
447
|
-
"args":
|
|
446
|
+
"command": ${JSON.stringify(launcherCommand.command)},
|
|
447
|
+
"args": ${JSON.stringify(launcherCommand.args)}
|
|
448
448
|
}
|
|
449
449
|
}
|
|
450
450
|
}
|
|
@@ -456,14 +456,14 @@ export async function runSetup() {
|
|
|
456
456
|
"servers": {
|
|
457
457
|
"yt-mcp": {
|
|
458
458
|
"transport": "stdio",
|
|
459
|
-
"command":
|
|
460
|
-
"args":
|
|
459
|
+
"command": ${JSON.stringify(launcherCommand.command)},
|
|
460
|
+
"args": ${JSON.stringify(launcherCommand.args)}
|
|
461
461
|
}
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
464
|
`);
|
|
465
465
|
console.log(" OpenClaw stdio CLI (recommended):");
|
|
466
|
-
console.log(` mcporter config add yt-mcp --command
|
|
466
|
+
console.log(` mcporter config add yt-mcp --command ${JSON.stringify(launcherCommand.command)} ${launcherCommand.args.map((arg) => `--arg ${JSON.stringify(arg)}`).join(" ")}`);
|
|
467
467
|
console.log(` OpenClaw uses ${PATHS.sharedAuthJson} for PAT/JWT, so env.YT_MCP_TOKEN is optional after setup.`);
|
|
468
468
|
console.log(` WorkBuddy MCP config: ${getWorkBuddyConfigPath()}`);
|
|
469
469
|
console.log(` CodeBuddy MCP config: ${getCodeBuddyConfigPath()}`);
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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.41";
|
|
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
|
+
}
|
package/dist/utils/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/config.js
CHANGED
|
@@ -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
|
|
65
|
+
return normalizeConfig(parsed);
|
|
62
66
|
}
|
|
63
67
|
try {
|
|
64
68
|
const raw = readFileSync(PATHS.configJson, "utf8");
|
|
65
|
-
|
|
66
|
-
return { ...DEFAULTS, ...parsed };
|
|
69
|
+
parsed = JSON.parse(raw);
|
|
67
70
|
}
|
|
68
71
|
catch {
|
|
69
|
-
|
|
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/dist/utils/launcher.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export interface LauncherSourceOptions {
|
|
2
|
+
packageSpec: string;
|
|
3
|
+
npmCacheDir: string;
|
|
4
|
+
npxCommand?: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface LauncherCommand {
|
|
7
|
+
command: string;
|
|
8
|
+
args: string[];
|
|
9
|
+
}
|
|
5
10
|
export declare function buildLauncherCommand(launcherPath?: string): LauncherCommand;
|
|
11
|
+
export declare function isRepairableNpxFailure(stderr: string): boolean;
|
|
12
|
+
export declare function resolveNpxCommand(): string | null;
|
|
6
13
|
export declare function buildLauncherSource(options?: Partial<LauncherSourceOptions>): string;
|
|
7
14
|
export declare function writeLauncherFile(options?: Partial<LauncherSourceOptions>): string;
|
package/dist/utils/launcher.js
CHANGED
|
@@ -1,19 +1,117 @@
|
|
|
1
1
|
import { PATHS } from "./config.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
4
5
|
const DEFAULT_PACKAGE_SPEC = "@mkterswingman/5mghost-yonder@latest";
|
|
5
6
|
export function buildLauncherCommand(launcherPath = PATHS.launcherJs) {
|
|
6
|
-
return
|
|
7
|
+
return {
|
|
8
|
+
command: process.execPath,
|
|
9
|
+
args: [launcherPath, "serve"],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function isRepairableNpxFailure(stderr) {
|
|
13
|
+
const lower = stderr.toLowerCase();
|
|
14
|
+
const referencesNpxDir = lower.includes("_npx/") || lower.includes("_npx\\");
|
|
15
|
+
return referencesNpxDir && (lower.includes("enotempty") || lower.includes("rename"));
|
|
16
|
+
}
|
|
17
|
+
export function resolveNpxCommand() {
|
|
18
|
+
try {
|
|
19
|
+
const lookup = process.platform === "win32" ? "where" : "which";
|
|
20
|
+
const target = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
21
|
+
const output = execFileSync(lookup, [target], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
22
|
+
return output.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
7
27
|
}
|
|
8
28
|
export function buildLauncherSource(options = {}) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
29
|
+
const packageSpec = options.packageSpec ?? DEFAULT_PACKAGE_SPEC;
|
|
30
|
+
const npmCacheDir = options.npmCacheDir ?? PATHS.npmCacheDir;
|
|
31
|
+
const npxCommand = "npxCommand" in options ? options.npxCommand : resolveNpxCommand();
|
|
32
|
+
return `#!/usr/bin/env node
|
|
33
|
+
import { existsSync, mkdirSync, renameSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { spawnSync } from "node:child_process";
|
|
36
|
+
|
|
37
|
+
const packageSpec = ${JSON.stringify(packageSpec)};
|
|
38
|
+
const npmCacheDir = ${JSON.stringify(npmCacheDir)};
|
|
39
|
+
const configuredNpxCommand = ${JSON.stringify(npxCommand)};
|
|
40
|
+
const args = process.argv.slice(2);
|
|
41
|
+
const targetArgs = args.length > 0 ? args : ["serve"];
|
|
42
|
+
|
|
43
|
+
function isSkillInstallerMode(subArgs) {
|
|
44
|
+
return subArgs[0] === "install-skills";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isRepairableNpxFailure(stderr) {
|
|
48
|
+
const lower = stderr.toLowerCase();
|
|
49
|
+
return (lower.includes("_npx/") || lower.includes("_npx\\\\"))
|
|
50
|
+
&& (lower.includes("enotempty") || lower.includes("rename"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getNpxCommand() {
|
|
54
|
+
return configuredNpxCommand || (process.platform === "win32" ? "npx.cmd" : "npx");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeSpawnError(result, npxBin) {
|
|
58
|
+
if (!result.error) return;
|
|
59
|
+
const message = result.error instanceof Error ? result.error.message : String(result.error);
|
|
60
|
+
process.stderr.write(\`[launcher] failed to start npx command "\${npxBin}": \${message}\\n\`);
|
|
61
|
+
process.stderr.write("[launcher] rerun setup from a terminal where Node.js/npm are available: yt-mcp setup\\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runNpx(subArgs, captureStdErrOnly = false) {
|
|
65
|
+
mkdirSync(npmCacheDir, { recursive: true });
|
|
66
|
+
const npxBin = getNpxCommand();
|
|
67
|
+
const result = spawnSync(npxBin, ["--yes", packageSpec, ...subArgs], {
|
|
68
|
+
env: { ...process.env, npm_config_cache: npmCacheDir },
|
|
69
|
+
// Why: probe runs before MCP starts; stdout must stay silent or it will corrupt stdio transport.
|
|
70
|
+
// The skill installer is an explicit one-shot CLI path, so it can inherit stdio safely.
|
|
71
|
+
stdio: captureStdErrOnly ? ["ignore", "ignore", "pipe"] : "inherit",
|
|
72
|
+
});
|
|
73
|
+
writeSpawnError(result, npxBin);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function rotateNpxDir() {
|
|
78
|
+
const npxDir = join(npmCacheDir, "_npx");
|
|
79
|
+
if (!existsSync(npxDir)) return false;
|
|
80
|
+
const backup = \`\${npxDir}.bad.\${new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 14)}\`;
|
|
81
|
+
renameSync(npxDir, backup);
|
|
82
|
+
process.stderr.write(\`[launcher] repaired corrupted npx cache: \${backup}\\n\`);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ensurePackageReady() {
|
|
87
|
+
const first = runNpx(["version"], true);
|
|
88
|
+
if ((first.status ?? 1) === 0) return;
|
|
89
|
+
|
|
90
|
+
const stderr = first.stderr ? String(first.stderr) : "";
|
|
91
|
+
if (!isRepairableNpxFailure(stderr) || !rotateNpxDir()) {
|
|
92
|
+
if (stderr) process.stderr.write(stderr);
|
|
93
|
+
process.exit(first.status ?? 1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const second = runNpx(["version"], true);
|
|
97
|
+
const secondStderr = second.stderr ? String(second.stderr) : "";
|
|
98
|
+
if ((second.status ?? 1) !== 0) {
|
|
99
|
+
if (secondStderr) process.stderr.write(secondStderr);
|
|
100
|
+
process.exit(second.status ?? 1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ensurePackageReady();
|
|
105
|
+
if (isSkillInstallerMode(targetArgs)) {
|
|
106
|
+
process.env.SHARED_CLIENT_INSTALL_SKILLS = "1";
|
|
107
|
+
}
|
|
108
|
+
const finalRun = runNpx(targetArgs, false);
|
|
109
|
+
process.exit(finalRun.status ?? (finalRun.error ? 1 : 0));
|
|
110
|
+
`;
|
|
13
111
|
}
|
|
14
112
|
export function writeLauncherFile(options = {}) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
113
|
+
mkdirSync(dirname(PATHS.launcherJs), { recursive: true });
|
|
114
|
+
const source = buildLauncherSource(options);
|
|
115
|
+
writeFileSync(PATHS.launcherJs, source, { encoding: "utf8", mode: 0o755 });
|
|
116
|
+
return PATHS.launcherJs;
|
|
19
117
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mkterswingman/5mghost-yonder",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.41",
|
|
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",
|