ymcp_manager 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/Unlicense ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <http://unlicense.org/>
package/bin/ymcp.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ require("../server.js");
@@ -0,0 +1,130 @@
1
+ # Guide on how to create MCP servers
2
+
3
+ Write concise, authoritative MCP server implementations for developers and LLMs.
4
+
5
+ Audience:
6
+ - LLMs (for tool discovery and reasoning)
7
+ - Developers writing local MCP servers
8
+
9
+ Tone:
10
+ - Technical
11
+ - Minimal
12
+ - Explicit rules
13
+
14
+ ## 1) What MCP Is
15
+ - MCP is a function-calling interface for LLMs.
16
+ - MCP tools are semantic actions, not transport APIs.
17
+ - MCP servers expose capabilities, not implementation internals.
18
+
19
+ ## 2) Design Rules (MANDATORY)
20
+ - Tools represent one semantic intention.
21
+ - Tool names are verbs (`add`, `validate`, `attach`, `detach`).
22
+ - Inputs are small, explicit, strongly typed JSON objects.
23
+ - Outputs are deterministic, structured JSON objects.
24
+ - Prefer IDs/paths over natural-language descriptions.
25
+ - LLMs do symbolic reasoning; tools do numeric operations and side effects.
26
+ - Hide REST/HTTP/shell/PowerShell behind MCP tools.
27
+
28
+ ## 3) Tool Shape (Canonical)
29
+ Tool:
30
+ - `name`: verb
31
+ - `inputs`: small JSON object
32
+ - `outputs`: small JSON object
33
+
34
+ Rules:
35
+ - No plain strings for structured payloads.
36
+ - No implicit units.
37
+
38
+ Example:
39
+ - `sin({ x: number }) -> { value: number }`
40
+
41
+ ## 4) Node.js MCP Server Requirements
42
+ - Use `@modelcontextprotocol/sdk`.
43
+ - Use stdio transport (`StdioServerTransport`).
44
+ - Do not expose HTTP as the MCP transport.
45
+ - One server may expose multiple tools.
46
+
47
+ ## 5) Repository Convention (REQUIRED)
48
+ Each MCP server repo must contain:
49
+ - `docs/mcp.json`
50
+
51
+ Required contract:
52
+
53
+ ```json
54
+ {
55
+ "name": "string",
56
+ "description": "string",
57
+ "entry": "relative path to MCP server entry js",
58
+ "command": "node",
59
+ "args": ["relative/path/to/entry.js"],
60
+ "tools": [
61
+ {
62
+ "name": "string",
63
+ "description": "string",
64
+ "inputs": { "argName": "type" },
65
+ "outputs": { "field": "type" }
66
+ }
67
+ ]
68
+ }
69
+ ```
70
+
71
+ Validation expectations used by `ymcp_manager`:
72
+ - `docs/mcp.json` must parse as JSON object.
73
+ - `name`, `description`, `entry`, `command` are non-empty strings.
74
+ - `command` must be `"node"`.
75
+ - `args` is an array of strings.
76
+ - `tools` is an array of objects with `name`, `description`, `inputs`, `outputs`.
77
+ - `entry` is relative and exists on disk.
78
+ - Entry source references `@modelcontextprotocol/sdk`.
79
+ - Entry source appears to use stdio transport.
80
+
81
+ ## 6) Execution Model
82
+ - MCP servers are launched by a runtime/manager, not by the LLM.
83
+ - LLMs do not start arbitrary processes.
84
+ - LLMs only call registered tools.
85
+ - If a tool is not registered, the LLM must not emulate side effects.
86
+
87
+ ## 7) Installation Feedback Loop (REQUIRED)
88
+ If an MCP server fails to attach in `ymcp_manager`, a file is written:
89
+ - `docs/mcp_installation_problem.md`
90
+
91
+ The LLM or developer maintaining that MCP server must:
92
+ 1. Read `docs/mcp_installation_problem.md`.
93
+ 2. Fix all listed problems in the repository.
94
+ 3. Re-run validation and attach.
95
+ 4. Delete `docs/mcp_installation_problem.md` after successful attach.
96
+
97
+ This rule is mandatory so failed installations produce actionable feedback.
98
+
99
+ ## 8) Anti-Patterns (FORBIDDEN)
100
+ - Exposing REST endpoints directly as tools.
101
+ - Exposing raw shell or PowerShell commands.
102
+ - Accepting unconstrained natural-language tool arguments.
103
+ - Overloading one tool with unrelated responsibilities.
104
+ - Parsing symbolic math inside tools.
105
+
106
+ ## 9) Minimal Node.js Example
107
+ ```js
108
+ #!/usr/bin/env node
109
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
110
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
111
+ import { z } from "zod";
112
+
113
+ const server = new McpServer({ name: "math-demo", version: "0.1.0" });
114
+
115
+ server.registerTool(
116
+ "sin",
117
+ { description: "Compute sine", inputSchema: { x: z.number() } },
118
+ async ({ x }) => ({
119
+ content: [{ type: "text", text: JSON.stringify({ value: Math.sin(x) }) }],
120
+ structuredContent: { value: Math.sin(x) }
121
+ })
122
+ );
123
+
124
+ await server.connect(new StdioServerTransport());
125
+ ```
126
+
127
+ Optimization goals:
128
+ - Automatic validation
129
+ - Tool discovery
130
+ - Meta-MCP attachment
package/install.bat ADDED
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ setlocal
3
+ node "%~dp0server.js" install
4
+ endlocal
package/install.sh ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
4
+ node "$SCRIPT_DIR/server.js" install
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "ymcp_manager",
3
+ "version": "0.1.0",
4
+ "description": "Local meta-MCP manager for adding, validating, and attaching MCP servers.",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "ymcp": "bin/ymcp.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node server.js",
11
+ "help": "node server.js help",
12
+ "validate": "node server.js validate",
13
+ "install:mcp": "node server.js install",
14
+ "uninstall:mcp": "node server.js uninstall",
15
+ "republish": "version-select && npm publish && npm uninstall -g ymcp_manager && npm install -g ymcp_manager && ymcp help"
16
+ },
17
+ "license": "Unlicense",
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.26.0",
20
+ "zod": "^3.25.76"
21
+ }
22
+ }
package/republish.bat ADDED
@@ -0,0 +1 @@
1
+ version-select && npm publish && npm uninstall -g ymcp_manager && npm install -g ymcp_manager && ymcp help
package/server.js ADDED
@@ -0,0 +1,764 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs/promises");
5
+ const fsSync = require("node:fs");
6
+ const path = require("node:path");
7
+ const { spawn } = require("node:child_process");
8
+ const { createRequire } = require("node:module");
9
+
10
+ const META_NAME = "ymcp-manager";
11
+ const META_VERSION = "0.1.0";
12
+ const HELP_DOC_PATH = path.join(__dirname, "docs", "mcp_server_guide.md");
13
+
14
+ const repos = new Map();
15
+ const attached = new Map();
16
+
17
+ function log(message) {
18
+ console.error(`[${META_NAME}] ${message}`);
19
+ }
20
+
21
+ function toResult(payload) {
22
+ return {
23
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
24
+ structuredContent: payload,
25
+ };
26
+ }
27
+
28
+ function normalizePath(inputPath) {
29
+ return path.resolve(inputPath);
30
+ }
31
+
32
+ function isObject(value) {
33
+ return value !== null && typeof value === "object" && !Array.isArray(value);
34
+ }
35
+
36
+ async function fileExists(targetPath) {
37
+ try {
38
+ await fs.access(targetPath);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ async function readManifest(repoPath) {
46
+ const manifestPath = path.join(repoPath, "docs", "mcp.json");
47
+ const raw = await fs.readFile(manifestPath, "utf8");
48
+ return JSON.parse(raw);
49
+ }
50
+
51
+ function getRepoByPathOrName(pathOrName) {
52
+ if (!pathOrName || typeof pathOrName !== "string") {
53
+ return null;
54
+ }
55
+
56
+ const normalizedInput = normalizePath(pathOrName);
57
+ if (repos.has(normalizedInput)) {
58
+ return repos.get(normalizedInput);
59
+ }
60
+
61
+ for (const repo of repos.values()) {
62
+ if (repo.name === pathOrName) {
63
+ return repo;
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function resolveEntry(repoPath, entry) {
70
+ return path.resolve(repoPath, entry);
71
+ }
72
+
73
+ async function validateRepoPath(repoPath) {
74
+ const errors = [];
75
+ const normalizedPath = normalizePath(repoPath);
76
+ const manifestPath = path.join(normalizedPath, "docs", "mcp.json");
77
+
78
+ if (!(await fileExists(normalizedPath))) {
79
+ errors.push(`Path does not exist: ${normalizedPath}`);
80
+ return { valid: false, errors };
81
+ }
82
+
83
+ if (!(await fileExists(manifestPath))) {
84
+ errors.push("Missing docs/mcp.json");
85
+ return { valid: false, errors };
86
+ }
87
+
88
+ let manifest;
89
+ try {
90
+ manifest = await readManifest(normalizedPath);
91
+ } catch (error) {
92
+ errors.push(`Invalid docs/mcp.json: ${error.message}`);
93
+ return { valid: false, errors };
94
+ }
95
+
96
+ if (!isObject(manifest)) {
97
+ errors.push("docs/mcp.json must be an object");
98
+ return { valid: false, errors };
99
+ }
100
+
101
+ if (typeof manifest.name !== "string" || !manifest.name.trim()) {
102
+ errors.push("Manifest field 'name' must be a non-empty string");
103
+ }
104
+ if (typeof manifest.description !== "string" || !manifest.description.trim()) {
105
+ errors.push("Manifest field 'description' must be a non-empty string");
106
+ }
107
+ if (typeof manifest.entry !== "string" || !manifest.entry.trim()) {
108
+ errors.push("Manifest field 'entry' must be a non-empty string");
109
+ }
110
+ if (typeof manifest.command !== "string" || !manifest.command.trim()) {
111
+ errors.push("Manifest field 'command' must be a non-empty string");
112
+ }
113
+ if (!Array.isArray(manifest.args) || !manifest.args.every((arg) => typeof arg === "string")) {
114
+ errors.push("Manifest field 'args' must be an array of strings");
115
+ }
116
+ if (!Array.isArray(manifest.tools)) {
117
+ errors.push("Manifest field 'tools' must be an array");
118
+ } else {
119
+ for (let i = 0; i < manifest.tools.length; i += 1) {
120
+ const tool = manifest.tools[i];
121
+ if (!isObject(tool)) {
122
+ errors.push(`tools[${i}] must be an object`);
123
+ continue;
124
+ }
125
+ if (typeof tool.name !== "string" || !tool.name.trim()) {
126
+ errors.push(`tools[${i}].name must be a non-empty string`);
127
+ }
128
+ if (typeof tool.description !== "string" || !tool.description.trim()) {
129
+ errors.push(`tools[${i}].description must be a non-empty string`);
130
+ }
131
+ if (!isObject(tool.inputs)) {
132
+ errors.push(`tools[${i}].inputs must be an object`);
133
+ }
134
+ if (!isObject(tool.outputs)) {
135
+ errors.push(`tools[${i}].outputs must be an object`);
136
+ }
137
+ }
138
+ }
139
+
140
+ if (manifest.command && manifest.command !== "node") {
141
+ errors.push("Manifest field 'command' must be 'node' for this local manager");
142
+ }
143
+
144
+ if (manifest.entry) {
145
+ if (path.isAbsolute(manifest.entry)) {
146
+ errors.push("Manifest field 'entry' must be a relative path");
147
+ } else {
148
+ const entryPath = resolveEntry(normalizedPath, manifest.entry);
149
+ if (!(await fileExists(entryPath))) {
150
+ errors.push(`Entry file not found: ${manifest.entry}`);
151
+ } else {
152
+ try {
153
+ const source = await fs.readFile(entryPath, "utf8");
154
+ if (!source.includes("@modelcontextprotocol/sdk")) {
155
+ errors.push("Entry file does not reference @modelcontextprotocol/sdk");
156
+ }
157
+ const looksLikeStdio =
158
+ /StdioServerTransport/.test(source) ||
159
+ /\bstdio\b/i.test(source) ||
160
+ /transport/i.test(source);
161
+ if (!looksLikeStdio) {
162
+ errors.push("Entry file does not appear to use stdio transport");
163
+ }
164
+ } catch (error) {
165
+ errors.push(`Unable to inspect entry file '${manifest.entry}': ${error.message}`);
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ if (Array.isArray(manifest.args)) {
172
+ const jsArgs = manifest.args.filter((arg) => arg.endsWith(".js"));
173
+ for (const argPath of jsArgs) {
174
+ if (!path.isAbsolute(argPath)) {
175
+ const candidate = path.resolve(normalizedPath, argPath);
176
+ if (!(await fileExists(candidate))) {
177
+ errors.push(`Args entry references missing file: ${argPath}`);
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ return {
184
+ valid: errors.length === 0,
185
+ errors: errors.length > 0 ? errors : undefined,
186
+ manifest,
187
+ path: normalizedPath,
188
+ };
189
+ }
190
+
191
+ function toServerInfo(repo) {
192
+ const proc = attached.get(repo.path);
193
+ return {
194
+ name: repo.name,
195
+ description: repo.description,
196
+ path: repo.path,
197
+ attached: Boolean(proc),
198
+ pid: proc ? proc.child.pid : null,
199
+ tools: Array.isArray(repo.tools) ? repo.tools : [],
200
+ options: repo.options || {},
201
+ };
202
+ }
203
+
204
+ async function addRepo(inputPath) {
205
+ const repoPath = normalizePath(inputPath);
206
+ if (repos.has(repoPath)) {
207
+ return { added: true, alreadyPresent: true, server: toServerInfo(repos.get(repoPath)) };
208
+ }
209
+
210
+ const validation = await validateRepoPath(repoPath);
211
+ const name = validation.manifest?.name || path.basename(repoPath);
212
+ const description = validation.manifest?.description || "";
213
+ const tools = validation.manifest?.tools || [];
214
+
215
+ const repo = {
216
+ path: repoPath,
217
+ name,
218
+ description,
219
+ tools,
220
+ manifest: validation.manifest || null,
221
+ options: {},
222
+ };
223
+ repos.set(repoPath, repo);
224
+ return {
225
+ added: true,
226
+ valid: validation.valid,
227
+ errors: validation.errors,
228
+ server: toServerInfo(repo),
229
+ };
230
+ }
231
+
232
+ async function attachRepo(pathOrName) {
233
+ const repo = getRepoByPathOrName(pathOrName);
234
+ if (!repo) {
235
+ return { attached: false, error: `Unknown MCP repo: ${pathOrName}` };
236
+ }
237
+ if (attached.has(repo.path)) {
238
+ return { attached: true, alreadyAttached: true, server: toServerInfo(repo) };
239
+ }
240
+
241
+ const validation = await validateRepoPath(repo.path);
242
+ if (!validation.valid) {
243
+ await writeInstallationProblem(repo.path, "validation_failed", validation.errors || []);
244
+ return { attached: false, errors: validation.errors };
245
+ }
246
+
247
+ repo.manifest = validation.manifest;
248
+ repo.name = validation.manifest.name;
249
+ repo.description = validation.manifest.description;
250
+ repo.tools = validation.manifest.tools || [];
251
+
252
+ const command = repo.options.command || validation.manifest.command || "node";
253
+ const args = repo.options.args || validation.manifest.args || [validation.manifest.entry];
254
+ const cwd = repo.options.cwd || repo.path;
255
+ const env = { ...process.env, ...(repo.options.env || {}) };
256
+
257
+ const child = spawn(command, args, {
258
+ cwd,
259
+ env,
260
+ stdio: ["pipe", "pipe", "pipe"],
261
+ windowsHide: true,
262
+ });
263
+
264
+ const record = { child, command, args, cwd, startedAt: new Date().toISOString() };
265
+ attached.set(repo.path, record);
266
+
267
+ child.on("error", async (error) => {
268
+ await writeInstallationProblem(repo.path, "spawn_error", [error.message]);
269
+ log(`child process error (${repo.name}): ${error.message}`);
270
+ });
271
+ child.on("exit", async (code, signal) => {
272
+ attached.delete(repo.path);
273
+ if (code !== 0) {
274
+ const msg = `process exited with code=${code} signal=${signal || "none"}`;
275
+ await writeInstallationProblem(repo.path, "process_exit", [msg]);
276
+ }
277
+ log(`detached ${repo.name} (exit=${code}, signal=${signal || "none"})`);
278
+ });
279
+ child.stderr.on("data", (buf) => {
280
+ const line = String(buf).trim();
281
+ if (line) {
282
+ log(`${repo.name}: ${line}`);
283
+ }
284
+ });
285
+
286
+ log(`attached ${repo.name} pid=${child.pid}`);
287
+ await clearInstallationProblem(repo.path);
288
+ return { attached: true, server: toServerInfo(repo) };
289
+ }
290
+
291
+ function detachRepo(pathOrName) {
292
+ const repo = getRepoByPathOrName(pathOrName);
293
+ if (!repo) {
294
+ return { detached: false, error: `Unknown MCP repo: ${pathOrName}` };
295
+ }
296
+
297
+ const record = attached.get(repo.path);
298
+ if (!record) {
299
+ return { detached: true, alreadyDetached: true, server: toServerInfo(repo) };
300
+ }
301
+
302
+ record.child.kill();
303
+ attached.delete(repo.path);
304
+ log(`detached ${repo.name}`);
305
+ return { detached: true, server: toServerInfo(repo) };
306
+ }
307
+
308
+ function removeRepo(pathOrName) {
309
+ if (!pathOrName) {
310
+ for (const repo of repos.values()) {
311
+ if (attached.has(repo.path)) {
312
+ detachRepo(repo.path);
313
+ }
314
+ }
315
+ const count = repos.size;
316
+ repos.clear();
317
+ return { removed: true, removedAll: true, count };
318
+ }
319
+
320
+ const repo = getRepoByPathOrName(pathOrName);
321
+ if (!repo) {
322
+ return { removed: false, error: `Unknown MCP repo: ${pathOrName}` };
323
+ }
324
+
325
+ if (attached.has(repo.path)) {
326
+ detachRepo(pathOrName);
327
+ }
328
+ repos.delete(repo.path);
329
+ return { removed: true, path: repo.path, name: repo.name };
330
+ }
331
+
332
+ async function reconfigRepo(pathOrName, opts) {
333
+ const repo = getRepoByPathOrName(pathOrName);
334
+ if (!repo) {
335
+ return { reconfigured: false, error: `Unknown MCP repo: ${pathOrName}` };
336
+ }
337
+ if (!isObject(opts)) {
338
+ return { reconfigured: false, error: "opts must be an object" };
339
+ }
340
+
341
+ repo.options = { ...repo.options, ...opts };
342
+ const shouldRestart = opts.restart === true;
343
+ const isAttached = attached.has(repo.path);
344
+
345
+ if (isAttached && shouldRestart) {
346
+ detachRepo(pathOrName);
347
+ const result = await attachRepo(pathOrName);
348
+ return {
349
+ reconfigured: true,
350
+ restarted: true,
351
+ attached: result.attached === true,
352
+ errors: result.errors,
353
+ options: repo.options,
354
+ };
355
+ }
356
+
357
+ return {
358
+ reconfigured: true,
359
+ restarted: false,
360
+ attached: isAttached,
361
+ options: repo.options,
362
+ };
363
+ }
364
+
365
+ async function closeAll() {
366
+ for (const [repoPath, record] of attached.entries()) {
367
+ const repo = repos.get(repoPath);
368
+ try {
369
+ record.child.kill();
370
+ if (repo) {
371
+ log(`shutdown detached ${repo.name}`);
372
+ }
373
+ } catch (error) {
374
+ log(`shutdown detach error (${repo?.name || repoPath}): ${error.message}`);
375
+ }
376
+ }
377
+ attached.clear();
378
+ }
379
+
380
+ function listAll() {
381
+ return Array.from(repos.values()).map(toServerInfo);
382
+ }
383
+
384
+ function listAttached() {
385
+ return Array.from(repos.values())
386
+ .filter((repo) => attached.has(repo.path))
387
+ .map(toServerInfo);
388
+ }
389
+
390
+ function getProblemDocPath(repoPath) {
391
+ return path.join(repoPath, "docs", "mcp_installation_problem.md");
392
+ }
393
+
394
+ async function writeInstallationProblem(repoPath, context, errors) {
395
+ if (!repoPath) {
396
+ return;
397
+ }
398
+ const docsDir = path.join(repoPath, "docs");
399
+ const problemPath = getProblemDocPath(repoPath);
400
+ const lines = [
401
+ "# MCP Installation Problem",
402
+ "",
403
+ `Generated: ${new Date().toISOString()}`,
404
+ `Context: ${context}`,
405
+ "",
406
+ "The manager could not attach this MCP server. Fix the issues below.",
407
+ "",
408
+ ];
409
+ if (Array.isArray(errors) && errors.length > 0) {
410
+ lines.push("## Errors");
411
+ for (const err of errors) {
412
+ lines.push(`- ${err}`);
413
+ }
414
+ lines.push("");
415
+ }
416
+ lines.push("## Required Follow-up");
417
+ lines.push("- Review this file and fix the issues in the repo.");
418
+ lines.push("- Re-run validation and attach.");
419
+ lines.push("- Delete `docs/mcp_installation_problem.md` after successful attach.");
420
+ lines.push("");
421
+ await fs.mkdir(docsDir, { recursive: true });
422
+ await fs.writeFile(problemPath, `${lines.join("\n")}\n`, "utf8");
423
+ }
424
+
425
+ async function clearInstallationProblem(repoPath) {
426
+ const problemPath = getProblemDocPath(repoPath);
427
+ if (await fileExists(problemPath)) {
428
+ await fs.unlink(problemPath);
429
+ }
430
+ }
431
+
432
+ async function readHelpDocument() {
433
+ return fs.readFile(HELP_DOC_PATH, "utf8");
434
+ }
435
+
436
+ function resolveCodexHome() {
437
+ if (process.env.CODEX_HOME && process.env.CODEX_HOME.trim()) {
438
+ return path.resolve(process.env.CODEX_HOME.trim());
439
+ }
440
+ const home = process.env.USERPROFILE || process.env.HOME;
441
+ if (!home) {
442
+ throw new Error("Unable to resolve home directory or CODEX_HOME");
443
+ }
444
+
445
+ const codex = path.join(home, ".codex");
446
+ const codexOlga = path.join(home, ".codex-olga");
447
+ if (require("node:fs").existsSync(codex)) {
448
+ return codex;
449
+ }
450
+ if (require("node:fs").existsSync(codexOlga)) {
451
+ return codexOlga;
452
+ }
453
+ return codex;
454
+ }
455
+
456
+ function getCodexConfigPath() {
457
+ return path.join(resolveCodexHome(), "config.toml");
458
+ }
459
+
460
+ function toTomlString(value) {
461
+ if (!value.includes("'")) {
462
+ return `'${value}'`;
463
+ }
464
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
465
+ }
466
+
467
+ function buildSelfMcpBlock() {
468
+ const serverPath = path.resolve(__filename);
469
+ return [
470
+ `[mcp_servers.${META_NAME}]`,
471
+ 'command = "node"',
472
+ `args = [${toTomlString(serverPath)}]`,
473
+ "startup_timeout_sec = 30",
474
+ ].join("\n");
475
+ }
476
+
477
+ function stripSelfMcpBlock(content) {
478
+ const sectionHeader = `[mcp_servers.${META_NAME}]`;
479
+ const lines = content.split(/\r?\n/);
480
+ const start = lines.findIndex((line) => line.trim() === sectionHeader);
481
+ if (start < 0) {
482
+ return { changed: false, content };
483
+ }
484
+
485
+ let end = start + 1;
486
+ while (end < lines.length) {
487
+ const trimmed = lines[end].trim();
488
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
489
+ break;
490
+ }
491
+ end += 1;
492
+ }
493
+
494
+ let from = start;
495
+ while (from > 0 && lines[from - 1].trim() === "") {
496
+ from -= 1;
497
+ }
498
+
499
+ lines.splice(from, end - from);
500
+ const next = `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
501
+ return { changed: true, content: next };
502
+ }
503
+
504
+ function hasRuntimeDependencies() {
505
+ const localRequire = createRequire(__filename);
506
+ try {
507
+ localRequire.resolve("@modelcontextprotocol/sdk/package.json");
508
+ localRequire.resolve("zod/package.json");
509
+ return true;
510
+ } catch {
511
+ return false;
512
+ }
513
+ }
514
+
515
+ async function installSelfIntoCodex() {
516
+ const depState = {
517
+ ready: hasRuntimeDependencies(),
518
+ note: "Run 'npm install' in ymcp_manager if ready=false",
519
+ };
520
+ const configPath = getCodexConfigPath();
521
+ const configDir = path.dirname(configPath);
522
+ await fs.mkdir(configDir, { recursive: true });
523
+
524
+ const existing = (await fileExists(configPath)) ? await fs.readFile(configPath, "utf8") : "";
525
+ const cleaned = stripSelfMcpBlock(existing).content.trimEnd();
526
+ const block = buildSelfMcpBlock();
527
+ const next = cleaned.length > 0 ? `${cleaned}\n\n${block}\n` : `${block}\n`;
528
+
529
+ await fs.writeFile(configPath, next, "utf8");
530
+ return { installed: true, configPath, dependencies: depState };
531
+ }
532
+
533
+ async function uninstallSelfFromCodex() {
534
+ const configPath = getCodexConfigPath();
535
+ if (!(await fileExists(configPath))) {
536
+ return { uninstalled: true, configPath, existed: false };
537
+ }
538
+
539
+ const existing = await fs.readFile(configPath, "utf8");
540
+ const removed = stripSelfMcpBlock(existing);
541
+ if (!removed.changed) {
542
+ return { uninstalled: true, configPath, existed: true, removed: false };
543
+ }
544
+
545
+ await fs.writeFile(configPath, removed.content, "utf8");
546
+ return { uninstalled: true, configPath, existed: true, removed: true };
547
+ }
548
+
549
+ function asPathCandidate(pathOrName) {
550
+ if (!pathOrName || typeof pathOrName !== "string") {
551
+ return null;
552
+ }
553
+ if (pathOrName.includes("/") || pathOrName.includes("\\") || pathOrName.includes(":")) {
554
+ return normalizePath(pathOrName);
555
+ }
556
+ return null;
557
+ }
558
+
559
+ async function ensureRepoKnown(pathOrName) {
560
+ const existing = getRepoByPathOrName(pathOrName);
561
+ if (existing) {
562
+ return existing;
563
+ }
564
+ const candidatePath = asPathCandidate(pathOrName);
565
+ if (!candidatePath) {
566
+ return null;
567
+ }
568
+ if (!(await fileExists(candidatePath))) {
569
+ return null;
570
+ }
571
+ await addRepo(candidatePath);
572
+ return getRepoByPathOrName(candidatePath);
573
+ }
574
+
575
+ function parseMaybeJson(input) {
576
+ if (!input) {
577
+ return {};
578
+ }
579
+ return JSON.parse(input);
580
+ }
581
+
582
+ async function runCliCommandIfAny() {
583
+ const command = process.argv[2];
584
+ if (!command) {
585
+ return false;
586
+ }
587
+
588
+ const arg = process.argv[3];
589
+ if (command === "help" || command === "--help" || command === "-h") {
590
+ console.log(await readHelpDocument());
591
+ return true;
592
+ }
593
+
594
+ if (command === "install") {
595
+ console.log(JSON.stringify(await installSelfIntoCodex(), null, 2));
596
+ return true;
597
+ }
598
+ if (command === "uninstall") {
599
+ console.log(JSON.stringify(await uninstallSelfFromCodex(), null, 2));
600
+ return true;
601
+ }
602
+ if (command === "add") {
603
+ console.log(JSON.stringify(await addRepo(arg), null, 2));
604
+ return true;
605
+ }
606
+ if (command === "validate") {
607
+ const repo = await ensureRepoKnown(arg);
608
+ const targetPath = repo ? repo.path : arg;
609
+ const validation = await validateRepoPath(targetPath);
610
+ console.log(
611
+ JSON.stringify(
612
+ {
613
+ valid: validation.valid,
614
+ errors: validation.errors,
615
+ path: validation.path || normalizePath(targetPath),
616
+ },
617
+ null,
618
+ 2
619
+ )
620
+ );
621
+ return true;
622
+ }
623
+ if (command === "attach") {
624
+ await ensureRepoKnown(arg);
625
+ console.log(JSON.stringify(await attachRepo(arg), null, 2));
626
+ return true;
627
+ }
628
+ if (command === "detach") {
629
+ console.log(JSON.stringify(detachRepo(arg), null, 2));
630
+ return true;
631
+ }
632
+ if (command === "list") {
633
+ console.log(JSON.stringify({ servers: listAll() }, null, 2));
634
+ return true;
635
+ }
636
+ if (command === "listAttached") {
637
+ console.log(JSON.stringify({ servers: listAttached() }, null, 2));
638
+ return true;
639
+ }
640
+ if (command === "remove") {
641
+ console.log(JSON.stringify(removeRepo(arg), null, 2));
642
+ return true;
643
+ }
644
+ if (command === "reconfig") {
645
+ const opts = parseMaybeJson(process.argv[4]);
646
+ console.log(JSON.stringify(await reconfigRepo(arg, opts), null, 2));
647
+ return true;
648
+ }
649
+
650
+ console.error(
651
+ `Unknown command '${command}'. Use: help | install | uninstall | add | validate | attach | detach | list | listAttached | remove | reconfig`
652
+ );
653
+ process.exitCode = 1;
654
+ return true;
655
+ }
656
+
657
+ async function main() {
658
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
659
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
660
+ const { z } = await import("zod");
661
+
662
+ const server = new McpServer({
663
+ name: META_NAME,
664
+ version: META_VERSION,
665
+ description: "Local meta-MCP manager for local git-hosted MCP servers",
666
+ });
667
+
668
+ const registerTool = (name, description, schema, handler) => {
669
+ if (typeof server.registerTool === "function") {
670
+ server.registerTool(name, { description, inputSchema: schema }, handler);
671
+ return;
672
+ }
673
+ if (typeof server.tool === "function") {
674
+ server.tool(name, description, schema, handler);
675
+ return;
676
+ }
677
+ throw new Error("Unsupported @modelcontextprotocol/sdk tool registration API");
678
+ };
679
+
680
+ registerTool("help", "Show MCP server authoring guide used by ymcp-manager", {}, async () =>
681
+ toResult({ guide: await readHelpDocument() })
682
+ );
683
+
684
+ registerTool(
685
+ "add",
686
+ "Add MCP repo path to manager list without attaching",
687
+ { path: z.string().min(1) },
688
+ async ({ path: repoPath }) => toResult(await addRepo(repoPath))
689
+ );
690
+
691
+ registerTool(
692
+ "validate",
693
+ "Validate MCP repo by path or known name",
694
+ { pathOrName: z.string().min(1) },
695
+ async ({ pathOrName }) => {
696
+ const repo = getRepoByPathOrName(pathOrName);
697
+ const targetPath = repo ? repo.path : pathOrName;
698
+ const validation = await validateRepoPath(targetPath);
699
+ return toResult({
700
+ valid: validation.valid,
701
+ errors: validation.errors,
702
+ path: validation.path || normalizePath(targetPath),
703
+ });
704
+ }
705
+ );
706
+
707
+ registerTool(
708
+ "attach",
709
+ "Validate and attach MCP repo process via stdio",
710
+ { pathOrName: z.string().min(1) },
711
+ async ({ pathOrName }) => toResult(await attachRepo(pathOrName))
712
+ );
713
+
714
+ registerTool(
715
+ "detach",
716
+ "Detach attached MCP repo process",
717
+ { pathOrName: z.string().min(1) },
718
+ async ({ pathOrName }) => toResult(detachRepo(pathOrName))
719
+ );
720
+
721
+ registerTool("listAttached", "List currently attached MCP repos", {}, async () =>
722
+ toResult({ servers: listAttached() })
723
+ );
724
+
725
+ registerTool("list", "List all known MCP repos", {}, async () => toResult({ servers: listAll() }));
726
+
727
+ registerTool(
728
+ "remove",
729
+ "Remove MCP repo from manager list",
730
+ { pathOrName: z.string().min(1).optional() },
731
+ async ({ pathOrName }) => toResult(removeRepo(pathOrName))
732
+ );
733
+
734
+ registerTool(
735
+ "reconfig",
736
+ "Update runtime options for a managed MCP server",
737
+ { pathOrName: z.string().min(1), opts: z.any() },
738
+ async ({ pathOrName, opts }) => toResult(await reconfigRepo(pathOrName, opts))
739
+ );
740
+
741
+ const transport = new StdioServerTransport();
742
+ await server.connect(transport);
743
+ log("meta MCP server started");
744
+
745
+ const shutdown = async () => {
746
+ await closeAll();
747
+ process.exit(0);
748
+ };
749
+
750
+ process.on("SIGINT", shutdown);
751
+ process.on("SIGTERM", shutdown);
752
+ }
753
+
754
+ runCliCommandIfAny()
755
+ .then((handled) => {
756
+ if (!handled) {
757
+ return main();
758
+ }
759
+ return undefined;
760
+ })
761
+ .catch((error) => {
762
+ console.error(`[${META_NAME}] fatal:`, error);
763
+ process.exit(1);
764
+ });
package/uninstall.bat ADDED
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ setlocal
3
+ node "%~dp0server.js" uninstall
4
+ endlocal
package/uninstall.sh ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
4
+ node "$SCRIPT_DIR/server.js" uninstall