mcp-for-i 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.
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ import { startControlPlaneServer } from "./server.js";
3
+ import { spawn } from "child_process";
4
+ import { fileURLToPath } from "url";
5
+ import { AUTOSTART_TASK_NAME, configureWindowsAutostart, getControlPlaneUrl, getWindowsAutostartStatus, isControlPlaneRunning, removeWindowsAutostart } from "./autostart.js";
6
+ const command = normalizeCommand(process.argv.slice(2));
7
+ switch (command) {
8
+ case "serve":
9
+ await serve();
10
+ break;
11
+ case "setup":
12
+ await setupAutostart();
13
+ break;
14
+ case "status":
15
+ await printStatus();
16
+ break;
17
+ case "remove":
18
+ case "uninstall":
19
+ await removeAutostart();
20
+ break;
21
+ case "open":
22
+ await openUi();
23
+ break;
24
+ case "help":
25
+ default:
26
+ printHelp();
27
+ break;
28
+ }
29
+ async function serve() {
30
+ try {
31
+ const server = await startControlPlaneServer();
32
+ process.stderr.write(`mcp-for-i control plane running at http://${server.host}:${server.port}\n`);
33
+ const shutdown = async () => {
34
+ await server.close();
35
+ process.exit(0);
36
+ };
37
+ process.on("SIGINT", shutdown);
38
+ process.on("SIGTERM", shutdown);
39
+ }
40
+ catch (err) {
41
+ if (err?.code === "EADDRINUSE") {
42
+ const url = getControlPlaneUrl();
43
+ process.stderr.write(`mcp-for-i control plane already running at ${url}\n`);
44
+ process.exit(0);
45
+ }
46
+ throw err;
47
+ }
48
+ }
49
+ async function setupAutostart() {
50
+ if (process.platform !== "win32") {
51
+ process.stderr.write("Autostart setup is currently implemented for Windows only.\n");
52
+ process.stderr.write(`Run manually with: mcp-for-i-control serve\n`);
53
+ return;
54
+ }
55
+ const scriptPath = fileURLToPath(import.meta.url);
56
+ await configureWindowsAutostart(scriptPath, process.execPath);
57
+ process.stderr.write(`Autostart configured (${AUTOSTART_TASK_NAME}).\n`);
58
+ process.stderr.write(`Control plane URL: ${getControlPlaneUrl()}\n`);
59
+ }
60
+ async function removeAutostart() {
61
+ if (process.platform !== "win32") {
62
+ process.stderr.write("Autostart removal is currently implemented for Windows only.\n");
63
+ return;
64
+ }
65
+ try {
66
+ await removeWindowsAutostart();
67
+ process.stderr.write(`Autostart removed (${AUTOSTART_TASK_NAME}).\n`);
68
+ }
69
+ catch (err) {
70
+ const msg = err?.message || String(err);
71
+ if (msg.includes("cannot find the file specified") || msg.includes("ERROR: The system cannot find")) {
72
+ process.stderr.write(`Autostart task not found (${AUTOSTART_TASK_NAME}).\n`);
73
+ return;
74
+ }
75
+ throw err;
76
+ }
77
+ }
78
+ async function printStatus() {
79
+ const url = getControlPlaneUrl();
80
+ const running = await isControlPlaneRunning(url);
81
+ process.stderr.write(`Control plane URL: ${url}\n`);
82
+ process.stderr.write(`Running: ${running ? "yes" : "no"}\n`);
83
+ if (process.platform === "win32") {
84
+ const auto = await getWindowsAutostartStatus();
85
+ process.stderr.write(`Autostart task: ${auto.installed ? "installed" : "not installed"}\n`);
86
+ if (auto.state) {
87
+ process.stderr.write(`Autostart state: ${auto.state}\n`);
88
+ }
89
+ }
90
+ else {
91
+ process.stderr.write("Autostart task: platform not managed by this command\n");
92
+ }
93
+ }
94
+ async function openUi() {
95
+ const url = getControlPlaneUrl();
96
+ if (process.platform === "win32") {
97
+ spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
98
+ }
99
+ else if (process.platform === "darwin") {
100
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
101
+ }
102
+ else {
103
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
104
+ }
105
+ process.stderr.write(`Opening ${url}\n`);
106
+ }
107
+ function printHelp() {
108
+ process.stderr.write(`mcp-for-i-control commands:\n`);
109
+ process.stderr.write(` serve Start control plane server (foreground)\n`);
110
+ process.stderr.write(` setup Configure autostart at login and launch now\n`);
111
+ process.stderr.write(` status Show running/autostart status\n`);
112
+ process.stderr.write(` remove Remove autostart configuration\n`);
113
+ process.stderr.write(` open Open UI URL in default browser\n`);
114
+ }
115
+ function normalizeCommand(args) {
116
+ const first = (args[0] || "serve").toLowerCase();
117
+ if (!first || first === "start")
118
+ return "serve";
119
+ return first;
120
+ }
@@ -0,0 +1,263 @@
1
+ import { spawn } from "child_process";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { AUTOSTART_TASK_NAME, configureWindowsAutostart, getControlPlaneUrl, getWindowsAutostartStatus, isControlPlaneRunning, removeWindowsAutostart } from "./autostart.js";
6
+ export class RuntimeService {
7
+ rootDir;
8
+ state = {
9
+ install: makeJob("install"),
10
+ updateMcp: makeJob("updateMcp"),
11
+ updateSkills: makeJob("updateSkills"),
12
+ setupAutostart: makeJob("setupAutostart"),
13
+ removeAutostart: makeJob("removeAutostart")
14
+ };
15
+ constructor(rootDir) {
16
+ this.rootDir = rootDir;
17
+ }
18
+ getStatus() {
19
+ return this.state;
20
+ }
21
+ async installOrRepair() {
22
+ return this.runJob("install", async (push) => {
23
+ await this.runNpm(["install"], this.rootDir, push);
24
+ await this.runNpm(["run", "build"], this.rootDir, push);
25
+ });
26
+ }
27
+ async updateMcp() {
28
+ return this.runJob("updateMcp", async (push) => {
29
+ await this.runCommand("git", ["pull", "--ff-only"], this.rootDir, push);
30
+ await this.runNpm(["install"], this.rootDir, push);
31
+ await this.runNpm(["run", "build"], this.rootDir, push);
32
+ });
33
+ }
34
+ async updateSkills(options) {
35
+ return this.runJob("updateSkills", async (push) => {
36
+ const repoUrl = sanitizeRepoUrl(options?.repoUrl) || process.env.MCP_FOR_I_SKILLS_REPO || DEFAULT_SKILLS_REPO;
37
+ const branch = sanitizeBranch(options?.branch) || process.env.MCP_FOR_I_SKILLS_BRANCH || "main";
38
+ const skillsDir = path.join(this.rootDir, "skills");
39
+ const hasSkills = await pathExists(skillsDir);
40
+ push(`Using skills repository: ${repoUrl} (${branch})`);
41
+ if (!hasSkills) {
42
+ await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, repoUrl, skillsDir], this.rootDir, push);
43
+ return;
44
+ }
45
+ const skillsGit = path.join(skillsDir, ".git");
46
+ if (await pathExists(skillsGit)) {
47
+ const currentRemote = await this.getGitRemoteUrl(skillsDir, push);
48
+ if (currentRemote && currentRemote !== repoUrl) {
49
+ push(`Switching skills remote from ${currentRemote} to ${repoUrl}`);
50
+ await this.runCommand("git", ["remote", "set-url", "origin", repoUrl], skillsDir, push);
51
+ }
52
+ await this.runCommand("git", ["fetch", "origin", branch], skillsDir, push);
53
+ await this.runCommand("git", ["checkout", branch], skillsDir, push);
54
+ await this.runCommand("git", ["pull", "--ff-only", "origin", branch], skillsDir, push);
55
+ return;
56
+ }
57
+ const backupDir = `${skillsDir}.backup-${Date.now()}`;
58
+ push(`Existing skills directory is not a git repository. Backing it up to ${backupDir}`);
59
+ await fs.rename(skillsDir, backupDir);
60
+ try {
61
+ await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, repoUrl, skillsDir], this.rootDir, push);
62
+ }
63
+ catch (error) {
64
+ push("Clone failed. Restoring previous skills directory.");
65
+ await safeRename(backupDir, skillsDir);
66
+ throw error;
67
+ }
68
+ });
69
+ }
70
+ async setupAutostart() {
71
+ return this.runJob("setupAutostart", async (push) => {
72
+ if (process.platform !== "win32") {
73
+ throw new Error("Autostart setup is currently supported on Windows only.");
74
+ }
75
+ const scriptPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
76
+ await configureWindowsAutostart(scriptPath, process.execPath);
77
+ push(`Autostart configured (${AUTOSTART_TASK_NAME}).`);
78
+ push(`Control plane URL: ${getControlPlaneUrl()}`);
79
+ });
80
+ }
81
+ async removeAutostart() {
82
+ return this.runJob("removeAutostart", async (push) => {
83
+ if (process.platform !== "win32") {
84
+ throw new Error("Autostart removal is currently supported on Windows only.");
85
+ }
86
+ try {
87
+ await removeWindowsAutostart();
88
+ push(`Autostart removed (${AUTOSTART_TASK_NAME}).`);
89
+ }
90
+ catch (err) {
91
+ const msg = err?.message || String(err);
92
+ if (msg.includes("cannot find the file specified") || msg.includes("ERROR: The system cannot find")) {
93
+ push(`Autostart task not found (${AUTOSTART_TASK_NAME}).`);
94
+ return;
95
+ }
96
+ throw err;
97
+ }
98
+ });
99
+ }
100
+ async getAutostartStatus() {
101
+ const url = getControlPlaneUrl();
102
+ const running = await isControlPlaneRunning(url);
103
+ if (process.platform !== "win32") {
104
+ return {
105
+ platform: process.platform,
106
+ running,
107
+ controlPlaneUrl: url,
108
+ supported: false,
109
+ installed: false
110
+ };
111
+ }
112
+ const task = await getWindowsAutostartStatus();
113
+ return {
114
+ platform: process.platform,
115
+ running,
116
+ controlPlaneUrl: url,
117
+ supported: true,
118
+ installed: task.installed,
119
+ state: task.state
120
+ };
121
+ }
122
+ async runJob(kind, fn) {
123
+ const current = this.state[kind];
124
+ if (current.status === "running") {
125
+ return current;
126
+ }
127
+ const job = makeJob(kind);
128
+ job.status = "running";
129
+ job.startedAt = new Date().toISOString();
130
+ this.state[kind] = job;
131
+ const push = (line) => {
132
+ job.output.push(line);
133
+ if (job.output.length > 300) {
134
+ job.output.splice(0, job.output.length - 300);
135
+ }
136
+ };
137
+ try {
138
+ await fn(push);
139
+ job.status = "success";
140
+ }
141
+ catch (err) {
142
+ job.status = "failed";
143
+ job.error = err?.message || String(err);
144
+ push(`ERROR: ${job.error}`);
145
+ }
146
+ finally {
147
+ job.finishedAt = new Date().toISOString();
148
+ }
149
+ return job;
150
+ }
151
+ runCommand(command, args, cwd, push) {
152
+ return new Promise((resolve, reject) => {
153
+ push(`$ ${command} ${args.join(" ")}`);
154
+ const child = spawn(command, args, {
155
+ cwd,
156
+ shell: process.platform === "win32"
157
+ });
158
+ child.stdout.on("data", chunk => push(String(chunk).trimEnd()));
159
+ child.stderr.on("data", chunk => push(String(chunk).trimEnd()));
160
+ child.on("error", reject);
161
+ child.on("exit", code => {
162
+ if (code === 0) {
163
+ resolve();
164
+ }
165
+ else {
166
+ reject(new Error(`Command failed (${code}): ${command} ${args.join(" ")}`));
167
+ }
168
+ });
169
+ });
170
+ }
171
+ async runNpm(args, cwd, push) {
172
+ const npm = await resolveNpmLaunch();
173
+ if (npm.mode === "node-cli") {
174
+ await this.runCommand(process.execPath, [npm.path, ...args], cwd, push);
175
+ return;
176
+ }
177
+ await this.runCommand(npm.path, args, cwd, push);
178
+ }
179
+ async getGitRemoteUrl(cwd, push) {
180
+ try {
181
+ const lines = [];
182
+ await this.runCommandCapture("git", ["remote", "get-url", "origin"], cwd, line => {
183
+ push(line);
184
+ lines.push(line);
185
+ });
186
+ const value = lines.join("\n").trim();
187
+ return value || undefined;
188
+ }
189
+ catch {
190
+ return undefined;
191
+ }
192
+ }
193
+ runCommandCapture(command, args, cwd, push) {
194
+ return new Promise((resolve, reject) => {
195
+ push(`$ ${command} ${args.join(" ")}`);
196
+ const child = spawn(command, args, {
197
+ cwd,
198
+ shell: process.platform === "win32"
199
+ });
200
+ child.stdout.on("data", chunk => push(String(chunk).trimEnd()));
201
+ child.stderr.on("data", chunk => push(String(chunk).trimEnd()));
202
+ child.on("error", reject);
203
+ child.on("exit", code => {
204
+ if (code === 0) {
205
+ resolve();
206
+ }
207
+ else {
208
+ reject(new Error(`Command failed (${code}): ${command} ${args.join(" ")}`));
209
+ }
210
+ });
211
+ });
212
+ }
213
+ }
214
+ function makeJob(id) {
215
+ return {
216
+ id,
217
+ status: "idle",
218
+ output: []
219
+ };
220
+ }
221
+ async function resolveNpmLaunch() {
222
+ if (process.env.npm_execpath && await pathExists(process.env.npm_execpath)) {
223
+ return { mode: "node-cli", path: process.env.npm_execpath };
224
+ }
225
+ const guessed = path.resolve(process.execPath, "..", "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
226
+ if (await pathExists(guessed))
227
+ return { mode: "node-cli", path: guessed };
228
+ return { mode: "command", path: process.platform === "win32" ? "npm.cmd" : "npm" };
229
+ }
230
+ function sanitizeRepoUrl(value) {
231
+ if (!value)
232
+ return undefined;
233
+ const trimmed = value.trim();
234
+ if (!trimmed)
235
+ return undefined;
236
+ return trimmed;
237
+ }
238
+ function sanitizeBranch(value) {
239
+ if (!value)
240
+ return undefined;
241
+ const trimmed = value.trim();
242
+ if (!trimmed)
243
+ return undefined;
244
+ return trimmed;
245
+ }
246
+ async function safeRename(from, to) {
247
+ try {
248
+ await fs.rename(from, to);
249
+ }
250
+ catch {
251
+ // best-effort rollback
252
+ }
253
+ }
254
+ const DEFAULT_SKILLS_REPO = "https://github.com/ashishthomas-pcr/mcp-for-i-skills.git";
255
+ async function pathExists(target) {
256
+ try {
257
+ await fs.access(target);
258
+ return true;
259
+ }
260
+ catch {
261
+ return false;
262
+ }
263
+ }
@@ -0,0 +1,180 @@
1
+ import http from "http";
2
+ import { URL } from "url";
3
+ import { ConfigStore } from "../config/store.js";
4
+ import { isKeychainAvailable } from "../security/credentialStore.js";
5
+ import { ConnectionService } from "./connectionService.js";
6
+ import { RuntimeService } from "./runtimeService.js";
7
+ import { renderControlPlaneHtml } from "./ui.js";
8
+ export async function startControlPlaneServer(opts) {
9
+ const host = opts?.host || process.env.MCP_FOR_I_CONTROL_HOST || "127.0.0.1";
10
+ const port = normalizePort(opts?.port || Number(process.env.MCP_FOR_I_CONTROL_PORT || 3980));
11
+ const rootDir = opts?.rootDir || process.cwd();
12
+ const store = new ConfigStore();
13
+ const connections = new ConnectionService(store);
14
+ const runtime = new RuntimeService(rootDir);
15
+ const server = http.createServer(async (req, res) => {
16
+ try {
17
+ await routeRequest(req, res, { connections, runtime });
18
+ }
19
+ catch (err) {
20
+ sendJson(res, 500, { error: err?.message || String(err) });
21
+ }
22
+ });
23
+ await new Promise((resolve, reject) => {
24
+ server.once("error", reject);
25
+ server.listen(port, host, () => resolve());
26
+ });
27
+ return {
28
+ host,
29
+ port,
30
+ close: () => new Promise((resolve, reject) => {
31
+ server.close(err => (err ? reject(err) : resolve()));
32
+ })
33
+ };
34
+ }
35
+ async function routeRequest(req, res, services) {
36
+ const method = req.method || "GET";
37
+ const url = new URL(req.url || "/", "http://127.0.0.1");
38
+ const pathname = url.pathname;
39
+ if (method === "GET" && pathname === "/") {
40
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
41
+ res.end(renderControlPlaneHtml());
42
+ return;
43
+ }
44
+ if (method === "GET" && pathname === "/api/health") {
45
+ sendJson(res, 200, { ok: true, keychainAvailable: await isKeychainAvailable() });
46
+ return;
47
+ }
48
+ if (method === "GET" && pathname === "/api/runtime/status") {
49
+ sendJson(res, 200, { jobs: services.runtime.getStatus() });
50
+ return;
51
+ }
52
+ if (method === "POST" && pathname === "/api/runtime/install") {
53
+ const job = await services.runtime.installOrRepair();
54
+ sendJson(res, 202, { job });
55
+ return;
56
+ }
57
+ if (method === "POST" && pathname === "/api/runtime/update/mcp") {
58
+ const job = await services.runtime.updateMcp();
59
+ sendJson(res, 202, { job });
60
+ return;
61
+ }
62
+ if (method === "POST" && pathname === "/api/runtime/update/skills") {
63
+ const body = await readJson(req);
64
+ const job = await services.runtime.updateSkills({
65
+ repoUrl: typeof body.repoUrl === "string" ? body.repoUrl : undefined,
66
+ branch: typeof body.branch === "string" ? body.branch : undefined
67
+ });
68
+ sendJson(res, 202, { job });
69
+ return;
70
+ }
71
+ if (method === "GET" && pathname === "/api/runtime/autostart/status") {
72
+ const status = await services.runtime.getAutostartStatus();
73
+ sendJson(res, 200, { status });
74
+ return;
75
+ }
76
+ if (method === "POST" && pathname === "/api/runtime/autostart/setup") {
77
+ const job = await services.runtime.setupAutostart();
78
+ sendJson(res, 202, { job });
79
+ return;
80
+ }
81
+ if (method === "POST" && pathname === "/api/runtime/autostart/remove") {
82
+ const job = await services.runtime.removeAutostart();
83
+ sendJson(res, 202, { job });
84
+ return;
85
+ }
86
+ if (method === "GET" && pathname === "/api/connections") {
87
+ const list = await services.connections.list();
88
+ sendJson(res, 200, { connections: list });
89
+ return;
90
+ }
91
+ if (method === "POST" && pathname === "/api/connections") {
92
+ const body = await readJson(req);
93
+ const created = await services.connections.add(body);
94
+ sendJson(res, 201, { connection: created });
95
+ return;
96
+ }
97
+ const matchConn = pathname.match(/^\/api\/connections\/([^/]+)$/);
98
+ if (matchConn) {
99
+ const name = decodeURIComponent(matchConn[1]);
100
+ if (method === "GET") {
101
+ const connection = await services.connections.get(name);
102
+ if (!connection) {
103
+ sendJson(res, 404, { error: `Connection ${name} not found` });
104
+ }
105
+ else {
106
+ sendJson(res, 200, { connection });
107
+ }
108
+ return;
109
+ }
110
+ if (method === "PUT") {
111
+ const body = await readJson(req);
112
+ const updated = await services.connections.update(name, body);
113
+ sendJson(res, 200, { connection: updated });
114
+ return;
115
+ }
116
+ if (method === "DELETE") {
117
+ await services.connections.delete(name);
118
+ sendNoContent(res);
119
+ return;
120
+ }
121
+ }
122
+ const matchRename = pathname.match(/^\/api\/connections\/([^/]+)\/rename$/);
123
+ if (matchRename && method === "POST") {
124
+ const oldName = decodeURIComponent(matchRename[1]);
125
+ const body = await readJson(req);
126
+ const newName = String(body.newName || "").trim();
127
+ if (!newName) {
128
+ sendJson(res, 400, { error: "newName is required" });
129
+ return;
130
+ }
131
+ const renamed = await services.connections.rename(oldName, newName);
132
+ sendJson(res, 200, { connection: renamed });
133
+ return;
134
+ }
135
+ const matchPwd = pathname.match(/^\/api\/connections\/([^/]+)\/password$/);
136
+ if (matchPwd && method === "POST") {
137
+ const name = decodeURIComponent(matchPwd[1]);
138
+ const body = await readJson(req);
139
+ const password = String(body.password || "");
140
+ if (!password) {
141
+ sendJson(res, 400, { error: "password is required" });
142
+ return;
143
+ }
144
+ await services.connections.setConnectionPassword(name, password);
145
+ sendNoContent(res);
146
+ return;
147
+ }
148
+ sendJson(res, 404, { error: "Not found" });
149
+ }
150
+ function sendJson(res, status, data) {
151
+ res.writeHead(status, {
152
+ "Content-Type": "application/json; charset=utf-8",
153
+ "Cache-Control": "no-store"
154
+ });
155
+ res.end(JSON.stringify(data));
156
+ }
157
+ function sendNoContent(res) {
158
+ res.writeHead(204);
159
+ res.end();
160
+ }
161
+ async function readJson(req) {
162
+ const chunks = [];
163
+ for await (const chunk of req) {
164
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
165
+ }
166
+ const raw = Buffer.concat(chunks).toString("utf8");
167
+ if (!raw.trim())
168
+ return {};
169
+ try {
170
+ return JSON.parse(raw);
171
+ }
172
+ catch {
173
+ throw new Error("Invalid JSON body");
174
+ }
175
+ }
176
+ function normalizePort(port) {
177
+ if (!Number.isFinite(port) || port <= 0 || port > 65535)
178
+ return 3980;
179
+ return Math.floor(port);
180
+ }