mcp-for-i 0.1.13 → 0.2.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/README.md +17 -0
- package/dist/config/store.js +229 -109
- package/dist/controlplane/auth.js +128 -0
- package/dist/controlplane/clientIntegrationService.js +83 -4
- package/dist/controlplane/commands.js +14 -9
- package/dist/controlplane/connectionService.js +110 -5
- package/dist/controlplane/connectionTest.js +329 -0
- package/dist/controlplane/errors.js +12 -0
- package/dist/controlplane/runtimeService.js +98 -26
- package/dist/controlplane/server.js +122 -11
- package/dist/controlplane/serverShared.js +25 -6
- package/dist/controlplane/serviceManager.js +113 -100
- package/dist/controlplane/ui.js +1742 -920
- package/dist/ibmi/CompileTools.js +29 -1
- package/dist/ibmi/IBMiContent.js +435 -63
- package/dist/ibmi/IbmDocs.js +685 -0
- package/dist/ibmi/LocalLanguageActions.js +129 -3
- package/dist/ibmi/Search.js +158 -52
- package/dist/ibmi/Tools.js +7 -10
- package/dist/ibmi/client.js +220 -31
- package/dist/ibmi/evfevent.js +256 -0
- package/dist/ibmi/knownHosts.js +190 -0
- package/dist/ibmi/libraryList.js +93 -0
- package/dist/ibmi/sourceInspect.js +815 -0
- package/dist/ibmi/sqlJob.js +888 -0
- package/dist/ibmi/sqlParse.js +317 -0
- package/dist/ibmi/tn5250/certTrust.js +59 -0
- package/dist/ibmi/tn5250/client.js +310 -0
- package/dist/ibmi/tn5250/datastream.js +109 -0
- package/dist/ibmi/tn5250/ebcdic.js +74 -0
- package/dist/ibmi/tn5250/index.js +6 -0
- package/dist/ibmi/tn5250/screen.js +827 -0
- package/dist/ibmi/tn5250/telnet.js +186 -0
- package/dist/ibmi/variables.js +18 -7
- package/dist/index.js +43 -22
- package/dist/mcp/annotations.js +155 -0
- package/dist/mcp/audit.js +490 -133
- package/dist/mcp/confirm.js +48 -0
- package/dist/mcp/context.js +43 -5
- package/dist/mcp/deployMethods.js +192 -0
- package/dist/mcp/logger.js +5 -10
- package/dist/mcp/modules/bulkMembers.js +476 -0
- package/dist/mcp/modules/changedLines.js +189 -0
- package/dist/mcp/modules/compare.js +198 -0
- package/dist/mcp/modules/compile.js +515 -0
- package/dist/mcp/modules/ifsOps.js +123 -0
- package/dist/mcp/modules/index.js +38 -0
- package/dist/mcp/modules/inspect.js +811 -0
- package/dist/mcp/modules/libl.js +246 -0
- package/dist/mcp/modules/members.js +171 -0
- package/dist/mcp/modules/objects.js +339 -0
- package/dist/mcp/modules/system.js +266 -0
- package/dist/mcp/modules/tn5250.js +515 -0
- package/dist/mcp/policy.js +412 -0
- package/dist/mcp/tools.js +1700 -698
- package/dist/security/credentialStore.js +62 -13
- package/dist/security/localPaths.js +138 -0
- package/dist/security/redact.js +79 -0
- package/dist/util/atomicWrite.js +101 -0
- package/dist/util/fileLock.js +206 -0
- package/docs/capabilities.md +50 -7
- package/docs/configuration.md +189 -60
- package/docs/examples.md +109 -73
- package/docs/overview.md +15 -15
- package/docs/security.md +101 -16
- package/docs/tools.md +363 -188
- package/package.json +56 -53
|
@@ -4,6 +4,7 @@ import os from "os";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { getPlatformDescriptor } from "./platform.js";
|
|
7
|
+
import { ControlPlaneError } from "./errors.js";
|
|
7
8
|
import { disableBackgroundService, enableBackgroundService, getServiceStatus } from "./serviceManager.js";
|
|
8
9
|
export class RuntimeService {
|
|
9
10
|
rootDir;
|
|
@@ -13,6 +14,7 @@ export class RuntimeService {
|
|
|
13
14
|
enableService: makeJob("enableService"),
|
|
14
15
|
disableService: makeJob("disableService")
|
|
15
16
|
};
|
|
17
|
+
pending = new Map();
|
|
16
18
|
constructor(rootDir) {
|
|
17
19
|
this.rootDir = rootDir;
|
|
18
20
|
}
|
|
@@ -20,6 +22,7 @@ export class RuntimeService {
|
|
|
20
22
|
return this.state;
|
|
21
23
|
}
|
|
22
24
|
async getVersions(options) {
|
|
25
|
+
const source = resolveSkillsSource(options);
|
|
23
26
|
const mode = await this.detectExecutionModeQuiet();
|
|
24
27
|
const checkedAt = new Date().toISOString();
|
|
25
28
|
const mcpInstalled = await this.readLocalPackageVersion(this.rootDir);
|
|
@@ -27,8 +30,7 @@ export class RuntimeService {
|
|
|
27
30
|
const mcpStatus = mcpInstalled && mcpLatest
|
|
28
31
|
? (mcpInstalled === mcpLatest ? "latest" : "update-available")
|
|
29
32
|
: "unknown";
|
|
30
|
-
const configuredRepo
|
|
31
|
-
const configuredBranch = sanitizeBranch(options?.branch) || process.env.MCP_FOR_I_SKILLS_BRANCH || "main";
|
|
33
|
+
const { repoUrl: configuredRepo, branch: configuredBranch } = source;
|
|
32
34
|
const skillsDir = path.join(this.rootDir, "skills");
|
|
33
35
|
const skillsGit = path.join(skillsDir, ".git");
|
|
34
36
|
const skills = {
|
|
@@ -89,14 +91,14 @@ export class RuntimeService {
|
|
|
89
91
|
});
|
|
90
92
|
}
|
|
91
93
|
async updateSkills(options) {
|
|
94
|
+
// Validate before starting the job so bad input is a 400, not a failed job.
|
|
95
|
+
const { repoUrl, branch } = resolveSkillsSource(options);
|
|
92
96
|
return this.runJob("updateSkills", async (push) => {
|
|
93
|
-
const repoUrl = sanitizeRepoUrl(options?.repoUrl) || process.env.MCP_FOR_I_SKILLS_REPO || DEFAULT_SKILLS_REPO;
|
|
94
|
-
const branch = sanitizeBranch(options?.branch) || process.env.MCP_FOR_I_SKILLS_BRANCH || "main";
|
|
95
97
|
const skillsDir = path.join(this.rootDir, "skills");
|
|
96
98
|
const hasSkills = await pathExists(skillsDir);
|
|
97
99
|
push(`Using skills repository: ${repoUrl} (${branch})`);
|
|
98
100
|
if (!hasSkills) {
|
|
99
|
-
await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, repoUrl, skillsDir], this.rootDir, push);
|
|
101
|
+
await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, "--", repoUrl, skillsDir], this.rootDir, push);
|
|
100
102
|
return;
|
|
101
103
|
}
|
|
102
104
|
const skillsGit = path.join(skillsDir, ".git");
|
|
@@ -104,7 +106,7 @@ export class RuntimeService {
|
|
|
104
106
|
const currentRemote = await this.getGitRemoteUrl(skillsDir, push);
|
|
105
107
|
if (currentRemote && currentRemote !== repoUrl) {
|
|
106
108
|
push(`Switching skills remote from ${currentRemote} to ${repoUrl}`);
|
|
107
|
-
await this.runCommand("git", ["remote", "set-url", "origin", repoUrl], skillsDir, push);
|
|
109
|
+
await this.runCommand("git", ["remote", "set-url", "--", "origin", repoUrl], skillsDir, push);
|
|
108
110
|
}
|
|
109
111
|
await this.runCommand("git", ["fetch", "origin", branch], skillsDir, push);
|
|
110
112
|
await this.runCommand("git", ["checkout", branch], skillsDir, push);
|
|
@@ -115,7 +117,7 @@ export class RuntimeService {
|
|
|
115
117
|
push(`Existing skills directory is not a git repository. Backing it up to ${backupDir}`);
|
|
116
118
|
await fs.rename(skillsDir, backupDir);
|
|
117
119
|
try {
|
|
118
|
-
await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, repoUrl, skillsDir], this.rootDir, push);
|
|
120
|
+
await this.runCommand("git", ["clone", "--depth", "1", "--branch", branch, "--", repoUrl, skillsDir], this.rootDir, push);
|
|
119
121
|
}
|
|
120
122
|
catch (error) {
|
|
121
123
|
push("Clone failed. Restoring previous skills directory.");
|
|
@@ -152,6 +154,15 @@ export class RuntimeService {
|
|
|
152
154
|
push(`Target: ${status.target}`);
|
|
153
155
|
});
|
|
154
156
|
}
|
|
157
|
+
/** Resolves when the given job has finished (immediately if it is not running). */
|
|
158
|
+
async waitForJob(kind) {
|
|
159
|
+
await this.pending.get(kind);
|
|
160
|
+
return this.state[kind];
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Starts a job in the background and returns it right away with status
|
|
164
|
+
* "running". Progress is read through getStatus() (GET /api/runtime/status).
|
|
165
|
+
*/
|
|
155
166
|
async runJob(kind, fn) {
|
|
156
167
|
const current = this.state[kind];
|
|
157
168
|
if (current.status === "running") {
|
|
@@ -167,19 +178,23 @@ export class RuntimeService {
|
|
|
167
178
|
job.output.splice(0, job.output.length - 300);
|
|
168
179
|
}
|
|
169
180
|
};
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
const done = (async () => {
|
|
182
|
+
try {
|
|
183
|
+
await fn(push);
|
|
184
|
+
job.status = "success";
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
job.status = "failed";
|
|
188
|
+
job.error = err?.message || String(err);
|
|
189
|
+
push(`ERROR: ${job.error}`);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
job.finishedAt = new Date().toISOString();
|
|
193
|
+
this.pending.delete(kind);
|
|
194
|
+
}
|
|
195
|
+
})();
|
|
196
|
+
this.pending.set(kind, done);
|
|
197
|
+
return { ...job, output: [...job.output] };
|
|
183
198
|
}
|
|
184
199
|
runCommand(command, args, cwd, push) {
|
|
185
200
|
return new Promise((resolve, reject) => {
|
|
@@ -379,7 +394,7 @@ export class RuntimeService {
|
|
|
379
394
|
}
|
|
380
395
|
async getRemoteBranchCommitQuiet(repoUrl, branch, short = false) {
|
|
381
396
|
try {
|
|
382
|
-
const out = await this.runCommandCaptureSilent("git", ["ls-remote", repoUrl, branch], this.rootDir);
|
|
397
|
+
const out = await this.runCommandCaptureSilent("git", ["ls-remote", "--", repoUrl, branch], this.rootDir);
|
|
383
398
|
const firstLine = out.split(/\r?\n/).find(Boolean);
|
|
384
399
|
if (!firstLine)
|
|
385
400
|
return undefined;
|
|
@@ -453,22 +468,79 @@ async function resolveNpmLaunch() {
|
|
|
453
468
|
}
|
|
454
469
|
return { mode: "command", path: process.platform === "win32" ? "npm.cmd" : "npm" };
|
|
455
470
|
}
|
|
456
|
-
|
|
457
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Picks the skills repository and branch (request, then environment, then
|
|
473
|
+
* defaults) and validates them. Throws a 400 error for unsafe values.
|
|
474
|
+
*/
|
|
475
|
+
export function resolveSkillsSource(options) {
|
|
476
|
+
const repoUrl = sanitizeRepoUrl(options?.repoUrl)
|
|
477
|
+
|| sanitizeRepoUrl(process.env.MCP_FOR_I_SKILLS_REPO)
|
|
478
|
+
|| DEFAULT_SKILLS_REPO;
|
|
479
|
+
const branch = sanitizeBranch(options?.branch)
|
|
480
|
+
|| sanitizeBranch(process.env.MCP_FOR_I_SKILLS_BRANCH)
|
|
481
|
+
|| "main";
|
|
482
|
+
return { repoUrl, branch };
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Accepts only https://, ssh:// and scp-style (user@host:path) git URLs.
|
|
486
|
+
* Anything that git could read as an option (leading "-") or that uses another
|
|
487
|
+
* transport (ext::, file://, local paths) is rejected.
|
|
488
|
+
*/
|
|
489
|
+
export function sanitizeRepoUrl(value) {
|
|
490
|
+
if (typeof value !== "string")
|
|
458
491
|
return undefined;
|
|
459
492
|
const trimmed = value.trim();
|
|
460
493
|
if (!trimmed)
|
|
461
494
|
return undefined;
|
|
462
|
-
|
|
495
|
+
const invalid = () => new ControlPlaneError(400, "repoUrl must be an https://, ssh:// or user@host:path git URL");
|
|
496
|
+
if (trimmed.length > 2048 || trimmed.startsWith("-") || /[\s\x00-\x1f\x7f]/.test(trimmed)) {
|
|
497
|
+
throw invalid();
|
|
498
|
+
}
|
|
499
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
500
|
+
let parsed;
|
|
501
|
+
try {
|
|
502
|
+
parsed = new URL(trimmed);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
throw invalid();
|
|
506
|
+
}
|
|
507
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "ssh:")
|
|
508
|
+
throw invalid();
|
|
509
|
+
if (!parsed.hostname || parsed.hostname.startsWith("-") || parsed.username.startsWith("-"))
|
|
510
|
+
throw invalid();
|
|
511
|
+
return trimmed;
|
|
512
|
+
}
|
|
513
|
+
// scp-like syntax: git@github.com:owner/repo.git
|
|
514
|
+
if (/^[A-Za-z0-9._-]+@[A-Za-z0-9][A-Za-z0-9.-]*:[A-Za-z0-9._~/-]+$/.test(trimmed) && !trimmed.includes("::")) {
|
|
515
|
+
return trimmed;
|
|
516
|
+
}
|
|
517
|
+
throw invalid();
|
|
463
518
|
}
|
|
464
|
-
|
|
465
|
-
|
|
519
|
+
/** Validates a branch name using the git check-ref-format rules. */
|
|
520
|
+
export function sanitizeBranch(value) {
|
|
521
|
+
if (typeof value !== "string")
|
|
466
522
|
return undefined;
|
|
467
523
|
const trimmed = value.trim();
|
|
468
524
|
if (!trimmed)
|
|
469
525
|
return undefined;
|
|
526
|
+
if (!isSafeBranchName(trimmed)) {
|
|
527
|
+
throw new ControlPlaneError(400, `Invalid branch name: ${JSON.stringify(trimmed.slice(0, 100))}`);
|
|
528
|
+
}
|
|
470
529
|
return trimmed;
|
|
471
530
|
}
|
|
531
|
+
function isSafeBranchName(name) {
|
|
532
|
+
if (name.length > 255)
|
|
533
|
+
return false;
|
|
534
|
+
if (name.startsWith("-") || name === "@" || name === "HEAD")
|
|
535
|
+
return false;
|
|
536
|
+
if (/[\x00-\x20\x7f~^:?*[\\]/.test(name))
|
|
537
|
+
return false;
|
|
538
|
+
if (name.includes("..") || name.includes("@{") || name.includes("//"))
|
|
539
|
+
return false;
|
|
540
|
+
if (name.startsWith("/") || name.endsWith("/") || name.endsWith("."))
|
|
541
|
+
return false;
|
|
542
|
+
return name.split("/").every(part => part.length > 0 && !part.startsWith(".") && !part.endsWith(".lock"));
|
|
543
|
+
}
|
|
472
544
|
async function safeRename(from, to) {
|
|
473
545
|
try {
|
|
474
546
|
await fs.rename(from, to);
|
|
@@ -10,46 +10,132 @@ import { ConnectionService } from "./connectionService.js";
|
|
|
10
10
|
import { RuntimeService } from "./runtimeService.js";
|
|
11
11
|
import { ClientIntegrationService } from "./clientIntegrationService.js";
|
|
12
12
|
import { renderControlPlaneHtml } from "./ui.js";
|
|
13
|
+
import { ControlPlaneError } from "./errors.js";
|
|
14
|
+
import { HEALTH_SERVICE_ID } from "./serverShared.js";
|
|
15
|
+
import { CONTROL_TOKEN_HEADER, ensureControlToken, isAllowedHostHeader, isAllowedOrigin, tokensMatch } from "./auth.js";
|
|
16
|
+
/** Largest accepted request body. Bigger bodies get 413. */
|
|
17
|
+
export const MAX_BODY_BYTES = 1024 * 1024;
|
|
13
18
|
export async function startControlPlaneServer(opts) {
|
|
14
19
|
const host = opts?.host || process.env.MCP_FOR_I_CONTROL_HOST || "127.0.0.1";
|
|
15
|
-
const port =
|
|
20
|
+
const port = typeof opts?.port === "number"
|
|
21
|
+
? normalizePort(opts.port, true)
|
|
22
|
+
: normalizePort(Number(process.env.MCP_FOR_I_CONTROL_PORT || 3980));
|
|
16
23
|
const rootDir = opts?.rootDir || process.env.MCP_FOR_I_ROOT_DIR || getDefaultRootDir();
|
|
24
|
+
const token = opts?.token || ensureControlToken();
|
|
17
25
|
const store = new ConfigStore();
|
|
18
26
|
const connections = new ConnectionService(store);
|
|
19
27
|
const runtime = new RuntimeService(rootDir);
|
|
20
28
|
const clients = new ClientIntegrationService(rootDir);
|
|
29
|
+
const guard = { token, port, extraHosts: extraAllowedHosts(host) };
|
|
21
30
|
const server = http.createServer(async (req, res) => {
|
|
22
31
|
try {
|
|
23
|
-
|
|
32
|
+
if (!checkRequest(req, res, guard))
|
|
33
|
+
return;
|
|
34
|
+
await routeRequest(req, res, { store, connections, runtime, clients, token });
|
|
24
35
|
}
|
|
25
36
|
catch (err) {
|
|
26
|
-
|
|
37
|
+
if (res.headersSent) {
|
|
38
|
+
res.destroy();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const status = err instanceof ControlPlaneError ? err.status : 500;
|
|
42
|
+
if (status === 413)
|
|
43
|
+
res.setHeader("Connection", "close");
|
|
44
|
+
sendJson(res, status, { error: err?.message || String(err) });
|
|
27
45
|
}
|
|
28
46
|
});
|
|
29
47
|
await new Promise((resolve, reject) => {
|
|
30
48
|
server.once("error", reject);
|
|
31
49
|
server.listen(port, host, () => resolve());
|
|
32
50
|
});
|
|
51
|
+
guard.port = server.address().port;
|
|
52
|
+
const closed = new Promise(resolve => server.once("close", () => resolve()));
|
|
33
53
|
return {
|
|
34
54
|
host,
|
|
35
|
-
port,
|
|
55
|
+
port: guard.port,
|
|
56
|
+
token,
|
|
57
|
+
closed,
|
|
36
58
|
close: () => new Promise((resolve, reject) => {
|
|
37
59
|
server.close(err => (err ? reject(err) : resolve()));
|
|
60
|
+
server.closeIdleConnections();
|
|
38
61
|
})
|
|
39
62
|
};
|
|
40
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Rejects requests that did not come from the local Web UI or CLI: a wrong Host
|
|
66
|
+
* header (DNS rebinding), a foreign Origin, a missing token on /api routes,
|
|
67
|
+
* non-JSON bodies, and bodies over the size limit.
|
|
68
|
+
*/
|
|
69
|
+
function checkRequest(req, res, guard) {
|
|
70
|
+
if (!isAllowedHostHeader(req.headers.host, guard.port, guard.extraHosts)) {
|
|
71
|
+
rejectRequest(req, res, 421, "Host not allowed");
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
|
|
75
|
+
if (!isAllowedOrigin(origin, guard.port, guard.extraHosts) || req.headers["sec-fetch-site"] === "cross-site") {
|
|
76
|
+
rejectRequest(req, res, 403, "Cross-origin requests are not allowed");
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const pathname = (req.url || "/").split("?")[0];
|
|
80
|
+
if (!pathname.startsWith("/api/"))
|
|
81
|
+
return true;
|
|
82
|
+
// /api/health answers without a token so launchers can probe liveness,
|
|
83
|
+
// but it only returns details when the token is present.
|
|
84
|
+
const isHealth = pathname === "/api/health" && (req.method === "GET" || req.method === "HEAD");
|
|
85
|
+
if (!isHealth && !tokensMatch(guard.token, req.headers[CONTROL_TOKEN_HEADER.toLowerCase()])) {
|
|
86
|
+
rejectRequest(req, res, 401, `Missing or invalid ${CONTROL_TOKEN_HEADER} header`);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const method = req.method || "GET";
|
|
90
|
+
if (method !== "GET" && method !== "HEAD" && method !== "DELETE") {
|
|
91
|
+
const contentType = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
|
|
92
|
+
if (contentType !== "application/json") {
|
|
93
|
+
rejectRequest(req, res, 415, "Content-Type must be application/json");
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const declared = Number(req.headers["content-length"]);
|
|
98
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
99
|
+
rejectRequest(req, res, 413, `Request body exceeds ${MAX_BODY_BYTES} bytes`);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
function rejectRequest(req, res, status, error) {
|
|
105
|
+
// Do not read a rejected body; close the connection once the reply is sent.
|
|
106
|
+
res.setHeader("Connection", "close");
|
|
107
|
+
sendJson(res, status, { error });
|
|
108
|
+
req.resume();
|
|
109
|
+
}
|
|
110
|
+
function extraAllowedHosts(bindHost) {
|
|
111
|
+
const lower = bindHost.trim().toLowerCase();
|
|
112
|
+
if (!lower || lower === "0.0.0.0" || lower === "::" || lower === "[::]")
|
|
113
|
+
return [];
|
|
114
|
+
return [lower];
|
|
115
|
+
}
|
|
41
116
|
async function routeRequest(req, res, services) {
|
|
42
117
|
const method = req.method || "GET";
|
|
43
118
|
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
44
119
|
const pathname = url.pathname;
|
|
45
120
|
if (method === "GET" && pathname === "/") {
|
|
46
|
-
res.writeHead(200, {
|
|
47
|
-
|
|
121
|
+
res.writeHead(200, {
|
|
122
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
123
|
+
"Cache-Control": "no-store",
|
|
124
|
+
"X-Frame-Options": "DENY",
|
|
125
|
+
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
126
|
+
"Referrer-Policy": "no-referrer",
|
|
127
|
+
"X-Content-Type-Options": "nosniff"
|
|
128
|
+
});
|
|
129
|
+
res.end(renderControlPlaneHtml(services.token));
|
|
48
130
|
return;
|
|
49
131
|
}
|
|
50
132
|
if (method === "GET" && pathname === "/api/health") {
|
|
133
|
+
if (!tokensMatch(services.token, req.headers[CONTROL_TOKEN_HEADER.toLowerCase()])) {
|
|
134
|
+
sendJson(res, 200, { ok: true, service: HEALTH_SERVICE_ID });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
51
137
|
const credentialStore = await getCredentialStoreStatus();
|
|
52
|
-
sendJson(res, 200, { ok: true, keychainAvailable: credentialStore.keychainAvailable, credentialStore });
|
|
138
|
+
sendJson(res, 200, { ok: true, service: HEALTH_SERVICE_ID, keychainAvailable: credentialStore.keychainAvailable, credentialStore });
|
|
53
139
|
return;
|
|
54
140
|
}
|
|
55
141
|
if (method === "GET" && pathname === "/api/settings") {
|
|
@@ -161,6 +247,18 @@ async function routeRequest(req, res, services) {
|
|
|
161
247
|
sendJson(res, 201, { connection: created });
|
|
162
248
|
return;
|
|
163
249
|
}
|
|
250
|
+
if (method === "POST" && pathname === "/api/connections/test") {
|
|
251
|
+
const body = await readJson(req);
|
|
252
|
+
const result = await services.connections.testDetails(body);
|
|
253
|
+
sendJson(res, 200, { result });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const matchTest = pathname.match(/^\/api\/connections\/([^/]+)\/test$/);
|
|
257
|
+
if (matchTest && method === "POST") {
|
|
258
|
+
const result = await services.connections.testSaved(decodeURIComponent(matchTest[1]));
|
|
259
|
+
sendJson(res, 200, { result });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
164
262
|
const matchConn = pathname.match(/^\/api\/connections\/([^/]+)$/);
|
|
165
263
|
if (matchConn) {
|
|
166
264
|
const name = decodeURIComponent(matchConn[1]);
|
|
@@ -227,20 +325,33 @@ function sendNoContent(res) {
|
|
|
227
325
|
}
|
|
228
326
|
async function readJson(req) {
|
|
229
327
|
const chunks = [];
|
|
328
|
+
let total = 0;
|
|
230
329
|
for await (const chunk of req) {
|
|
231
|
-
|
|
330
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
331
|
+
total += buf.length;
|
|
332
|
+
if (total > MAX_BODY_BYTES) {
|
|
333
|
+
throw new ControlPlaneError(413, `Request body exceeds ${MAX_BODY_BYTES} bytes`);
|
|
334
|
+
}
|
|
335
|
+
chunks.push(buf);
|
|
232
336
|
}
|
|
233
337
|
const raw = Buffer.concat(chunks).toString("utf8");
|
|
234
338
|
if (!raw.trim())
|
|
235
339
|
return {};
|
|
340
|
+
let parsed;
|
|
236
341
|
try {
|
|
237
|
-
|
|
342
|
+
parsed = JSON.parse(raw);
|
|
238
343
|
}
|
|
239
344
|
catch {
|
|
240
|
-
throw new
|
|
345
|
+
throw new ControlPlaneError(400, "Invalid JSON body");
|
|
346
|
+
}
|
|
347
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
348
|
+
throw new ControlPlaneError(400, "JSON body must be an object");
|
|
241
349
|
}
|
|
350
|
+
return parsed;
|
|
242
351
|
}
|
|
243
|
-
function normalizePort(port) {
|
|
352
|
+
function normalizePort(port, allowEphemeral = false) {
|
|
353
|
+
if (allowEphemeral && port === 0)
|
|
354
|
+
return 0;
|
|
244
355
|
if (!Number.isFinite(port) || port <= 0 || port > 65535)
|
|
245
356
|
return 3980;
|
|
246
357
|
return Math.floor(port);
|
|
@@ -1,16 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { CONTROL_TOKEN_HEADER, readControlToken } from "./auth.js";
|
|
2
|
+
/** Marker returned by /api/health so callers know they reached this service. */
|
|
3
|
+
export const HEALTH_SERVICE_ID = "mcp-for-i-control";
|
|
4
|
+
export function getControlPlanePort() {
|
|
3
5
|
const portRaw = Number(process.env.MCP_FOR_I_CONTROL_PORT || 3980);
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
return Number.isFinite(portRaw) && portRaw > 0 && portRaw <= 65535 ? Math.floor(portRaw) : 3980;
|
|
7
|
+
}
|
|
8
|
+
export function getControlPlaneUrl() {
|
|
9
|
+
const configured = (process.env.MCP_FOR_I_CONTROL_HOST || "").trim();
|
|
10
|
+
// A wildcard bind address is not a valid Host for the server's checks.
|
|
11
|
+
const host = !configured || ["0.0.0.0", "::", "[::]"].includes(configured) ? "127.0.0.1" : configured;
|
|
12
|
+
const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
13
|
+
return `http://${urlHost}:${getControlPlanePort()}`;
|
|
14
|
+
}
|
|
15
|
+
/** Headers the CLI sends to the control plane (includes the per-install token). */
|
|
16
|
+
export function getControlPlaneAuthHeaders() {
|
|
17
|
+
const token = readControlToken();
|
|
18
|
+
return token ? { [CONTROL_TOKEN_HEADER]: token } : {};
|
|
6
19
|
}
|
|
7
20
|
export async function isControlPlaneRunning(baseUrl = getControlPlaneUrl()) {
|
|
8
21
|
try {
|
|
9
22
|
const controller = new AbortController();
|
|
10
23
|
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
11
|
-
const res = await fetch(`${baseUrl}/api/health`, {
|
|
24
|
+
const res = await fetch(`${baseUrl}/api/health`, {
|
|
25
|
+
signal: controller.signal,
|
|
26
|
+
headers: getControlPlaneAuthHeaders()
|
|
27
|
+
});
|
|
12
28
|
clearTimeout(timeout);
|
|
13
|
-
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
return false;
|
|
31
|
+
const body = await res.json().catch(() => null);
|
|
32
|
+
return Boolean(body?.ok);
|
|
14
33
|
}
|
|
15
34
|
catch {
|
|
16
35
|
return false;
|