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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { getConfigDir } from "../config/store.js";
|
|
5
|
+
import { writeFileAtomic } from "../util/atomicWrite.js";
|
|
6
|
+
/**
|
|
7
|
+
* Trust-on-first-use SSH host key store (also holds pinned TLS certificates
|
|
8
|
+
* for the Mapepire SQL server, see checkCertificate).
|
|
9
|
+
*
|
|
10
|
+
* Fingerprints are kept as OpenSSH-style SHA256 strings ("SHA256:<base64>")
|
|
11
|
+
* per "host:port" in known_hosts.json in the mcp-for-i config directory.
|
|
12
|
+
*/
|
|
13
|
+
export const ACCEPT_NEW_HOSTKEY_ENV = "MCP_FOR_I_ACCEPT_NEW_HOSTKEY";
|
|
14
|
+
export class HostKeyMismatchError extends Error {
|
|
15
|
+
hostKey;
|
|
16
|
+
expected;
|
|
17
|
+
presented;
|
|
18
|
+
filePath;
|
|
19
|
+
constructor(hostKey, expected, presented, filePath) {
|
|
20
|
+
super(`SSH host key for ${hostKey} has changed. Stored ${expected}, server presented ${presented}. ` +
|
|
21
|
+
`This can mean a man-in-the-middle attack, so the connection was refused. ` +
|
|
22
|
+
`If the change is expected (for example the system was rebuilt or its SSH keys were regenerated), ` +
|
|
23
|
+
`set ${ACCEPT_NEW_HOSTKEY_ENV}=${hostKey} (or =${presented}) and connect again, ` +
|
|
24
|
+
`or remove the "${hostKey}" entry from ${filePath}.`);
|
|
25
|
+
this.hostKey = hostKey;
|
|
26
|
+
this.expected = expected;
|
|
27
|
+
this.presented = presented;
|
|
28
|
+
this.filePath = filePath;
|
|
29
|
+
this.name = "HostKeyMismatchError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function getKnownHostsPath() {
|
|
33
|
+
return path.join(getConfigDir(), "known_hosts.json");
|
|
34
|
+
}
|
|
35
|
+
export function hostKeyFingerprint(key) {
|
|
36
|
+
return `SHA256:${crypto.createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
|
|
37
|
+
}
|
|
38
|
+
/** Reads the key type ("ssh-ed25519", "rsa-sha2-...", ...) from an SSH public key blob. */
|
|
39
|
+
export function hostKeyType(key) {
|
|
40
|
+
if (key.length < 4)
|
|
41
|
+
return undefined;
|
|
42
|
+
const len = key.readUInt32BE(0);
|
|
43
|
+
if (len <= 0 || len > 64 || 4 + len > key.length)
|
|
44
|
+
return undefined;
|
|
45
|
+
const type = key.subarray(4, 4 + len).toString("ascii");
|
|
46
|
+
return /^[\x21-\x7e]+$/.test(type) ? type : undefined;
|
|
47
|
+
}
|
|
48
|
+
export function knownHostId(host, port) {
|
|
49
|
+
const h = String(host).trim().toLowerCase();
|
|
50
|
+
return h.includes(":") && !h.startsWith("[") ? `[${h}]:${port}` : `${h}:${port}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* True when the accept setting allows replacing the stored key for this host.
|
|
54
|
+
* Accepted forms (comma separated): "1"/"true"/"yes"/"all" for any host,
|
|
55
|
+
* "host", "host:port" or the new "SHA256:..." fingerprint.
|
|
56
|
+
*/
|
|
57
|
+
export function isNewHostKeyAccepted(acceptNew, host, port, fingerprint) {
|
|
58
|
+
if (!acceptNew)
|
|
59
|
+
return false;
|
|
60
|
+
const id = knownHostId(host, port);
|
|
61
|
+
const bareHost = String(host).trim().toLowerCase();
|
|
62
|
+
return acceptNew.split(",").map(v => v.trim()).filter(Boolean).some(value => {
|
|
63
|
+
const lower = value.toLowerCase();
|
|
64
|
+
if (["1", "true", "yes", "all"].includes(lower))
|
|
65
|
+
return true;
|
|
66
|
+
if (value.startsWith("SHA256:"))
|
|
67
|
+
return value === fingerprint;
|
|
68
|
+
return lower === id || lower === bareHost;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export async function readKnownHosts(filePath) {
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (err?.code === "ENOENT")
|
|
78
|
+
return { version: 1, hosts: {} };
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(raw);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Never silently forget trusted keys: a damaged file must be fixed by hand.
|
|
87
|
+
throw new Error(`Known hosts file ${filePath} is not valid JSON. Fix or remove it before connecting.`);
|
|
88
|
+
}
|
|
89
|
+
const hosts = parsed && typeof parsed.hosts === "object" && parsed.hosts ? parsed.hosts : {};
|
|
90
|
+
const certificates = parsed && typeof parsed.certificates === "object" && parsed.certificates ? parsed.certificates : undefined;
|
|
91
|
+
return certificates ? { version: 1, hosts, certificates } : { version: 1, hosts };
|
|
92
|
+
}
|
|
93
|
+
export async function writeKnownHosts(filePath, data) {
|
|
94
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
95
|
+
// Retries the rename on Windows sharing errors (antivirus, indexer).
|
|
96
|
+
await writeFileAtomic(filePath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
97
|
+
}
|
|
98
|
+
let queue = Promise.resolve();
|
|
99
|
+
function serialized(run) {
|
|
100
|
+
const result = queue.then(run, run);
|
|
101
|
+
queue = result.catch(() => undefined);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Checks a presented host key against the store. Adds it on first use,
|
|
106
|
+
* replaces it only when explicitly accepted, and otherwise throws
|
|
107
|
+
* HostKeyMismatchError.
|
|
108
|
+
*/
|
|
109
|
+
export function checkHostKey(options) {
|
|
110
|
+
const run = async () => {
|
|
111
|
+
const filePath = options.filePath || getKnownHostsPath();
|
|
112
|
+
const acceptNew = options.acceptNew ?? process.env[ACCEPT_NEW_HOSTKEY_ENV];
|
|
113
|
+
const id = knownHostId(options.host, options.port);
|
|
114
|
+
const fingerprint = hostKeyFingerprint(options.key);
|
|
115
|
+
const keyType = hostKeyType(options.key);
|
|
116
|
+
const data = await readKnownHosts(filePath);
|
|
117
|
+
const existing = data.hosts[id];
|
|
118
|
+
const now = new Date().toISOString();
|
|
119
|
+
if (existing && existing.fingerprint === fingerprint) {
|
|
120
|
+
return { status: "trusted", fingerprint };
|
|
121
|
+
}
|
|
122
|
+
if (existing && !isNewHostKeyAccepted(acceptNew, options.host, options.port, fingerprint)) {
|
|
123
|
+
throw new HostKeyMismatchError(id, existing.fingerprint, fingerprint, filePath);
|
|
124
|
+
}
|
|
125
|
+
data.hosts[id] = {
|
|
126
|
+
fingerprint,
|
|
127
|
+
...(keyType ? { keyType } : {}),
|
|
128
|
+
addedAt: existing ? existing.addedAt : now,
|
|
129
|
+
updatedAt: now
|
|
130
|
+
};
|
|
131
|
+
await writeKnownHosts(filePath, data);
|
|
132
|
+
return { status: existing ? "replaced" : "added", fingerprint };
|
|
133
|
+
};
|
|
134
|
+
return serialized(run);
|
|
135
|
+
}
|
|
136
|
+
export class CertificateMismatchError extends Error {
|
|
137
|
+
hostKey;
|
|
138
|
+
expected;
|
|
139
|
+
presented;
|
|
140
|
+
filePath;
|
|
141
|
+
constructor(hostKey, expected, presented, filePath) {
|
|
142
|
+
super(`TLS certificate for ${hostKey} has changed. Stored ${expected}, server presented ${presented}. ` +
|
|
143
|
+
`This can mean a man-in-the-middle attack, so the connection was refused. ` +
|
|
144
|
+
`If the change is expected (for example the server certificate was renewed), ` +
|
|
145
|
+
`set ${ACCEPT_NEW_HOSTKEY_ENV}=${hostKey} (or =${presented}) and connect again, ` +
|
|
146
|
+
`or remove the "${hostKey}" entry under "certificates" in ${filePath}.`);
|
|
147
|
+
this.hostKey = hostKey;
|
|
148
|
+
this.expected = expected;
|
|
149
|
+
this.presented = presented;
|
|
150
|
+
this.filePath = filePath;
|
|
151
|
+
this.name = "CertificateMismatchError";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** SHA-256 fingerprint of a DER certificate, in the same "SHA256:<base64>" form as host keys. */
|
|
155
|
+
export function certificateFingerprint(der) {
|
|
156
|
+
return hostKeyFingerprint(der);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Trust-on-first-use check for a TLS certificate, stored next to the SSH host
|
|
160
|
+
* keys. Same rules as checkHostKey: added on first use, replaced only when
|
|
161
|
+
* explicitly accepted, otherwise CertificateMismatchError.
|
|
162
|
+
*/
|
|
163
|
+
export function checkCertificate(options) {
|
|
164
|
+
const run = async () => {
|
|
165
|
+
const filePath = options.filePath || getKnownHostsPath();
|
|
166
|
+
const acceptNew = options.acceptNew ?? process.env[ACCEPT_NEW_HOSTKEY_ENV];
|
|
167
|
+
const id = knownHostId(options.host, options.port);
|
|
168
|
+
const fingerprint = certificateFingerprint(options.der);
|
|
169
|
+
const data = await readKnownHosts(filePath);
|
|
170
|
+
const certificates = data.certificates || {};
|
|
171
|
+
const existing = certificates[id];
|
|
172
|
+
const now = new Date().toISOString();
|
|
173
|
+
if (existing && existing.fingerprint === fingerprint) {
|
|
174
|
+
return { status: "trusted", fingerprint };
|
|
175
|
+
}
|
|
176
|
+
if (existing && !isNewHostKeyAccepted(acceptNew, options.host, options.port, fingerprint)) {
|
|
177
|
+
throw new CertificateMismatchError(id, existing.fingerprint, fingerprint, filePath);
|
|
178
|
+
}
|
|
179
|
+
certificates[id] = {
|
|
180
|
+
fingerprint,
|
|
181
|
+
...(options.subject ? { subject: options.subject } : {}),
|
|
182
|
+
addedAt: existing ? existing.addedAt : now,
|
|
183
|
+
updatedAt: now
|
|
184
|
+
};
|
|
185
|
+
data.certificates = certificates;
|
|
186
|
+
await writeKnownHosts(filePath, data);
|
|
187
|
+
return { status: existing ? "replaced" : "added", fingerprint };
|
|
188
|
+
};
|
|
189
|
+
return serialized(run);
|
|
190
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Tools } from "./Tools.js";
|
|
2
|
+
const LIBRARY_NAME = /^[A-Z#@$][A-Z0-9#@$._]{0,9}$/;
|
|
3
|
+
export function isLibraryName(name) {
|
|
4
|
+
return LIBRARY_NAME.test(name);
|
|
5
|
+
}
|
|
6
|
+
export function desiredLibraryList(config) {
|
|
7
|
+
const names = (Array.isArray(config.libraryList) ? config.libraryList : [])
|
|
8
|
+
.map(lib => String(lib ?? "").trim().toUpperCase())
|
|
9
|
+
.filter(Boolean);
|
|
10
|
+
const current = String(config.currentLibrary ?? "").trim().toUpperCase();
|
|
11
|
+
const invalid = [...names, ...(current ? [current] : [])].filter(lib => !isLibraryName(lib)).filter(Tools.distinct);
|
|
12
|
+
const libraries = names.filter(isLibraryName).filter(Tools.distinct);
|
|
13
|
+
const currentLibrary = current && isLibraryName(current) ? current : undefined;
|
|
14
|
+
return {
|
|
15
|
+
libraries,
|
|
16
|
+
currentLibrary,
|
|
17
|
+
invalid,
|
|
18
|
+
signature: JSON.stringify([libraries, currentLibrary ?? null])
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The CL command that puts a library list in effect. With no user libraries
|
|
23
|
+
* only the current library is changed, so an empty setting does not wipe the
|
|
24
|
+
* job's library list.
|
|
25
|
+
*/
|
|
26
|
+
export function libraryListCommand(libraries, currentLibrary) {
|
|
27
|
+
if (libraries.length > 0)
|
|
28
|
+
return `CHGLIBL LIBL(${libraries.join(" ")})${currentLibrary ? ` CURLIB(${currentLibrary})` : ""}`;
|
|
29
|
+
if (currentLibrary)
|
|
30
|
+
return `CHGCURLIB CURLIB(${currentLibrary})`;
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
/** All library names a desired list refers to. */
|
|
34
|
+
export function referencedLibraries(desired) {
|
|
35
|
+
return [...desired.libraries, ...(desired.currentLibrary ? [desired.currentLibrary] : [])].filter(Tools.distinct);
|
|
36
|
+
}
|
|
37
|
+
/** SQL that returns OBJNAME for each of `names` that exists as a library. */
|
|
38
|
+
export function existingLibrariesSql(names, allSimple = true) {
|
|
39
|
+
const inList = names.map(lib => `'${lib.replace(/'/g, "''")}'`).join(", ");
|
|
40
|
+
const filter = allSimple ? ", '*ALLSIMPLE'" : "";
|
|
41
|
+
return `select OBJNAME from table(QSYS2.OBJECT_STATISTICS('QSYS', '*LIB'${filter})) X where OBJNAME in (${inList})`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Drops libraries that do not exist (CHGLIBL fails entirely when one is
|
|
45
|
+
* missing) and describes what was left out.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveAgainstExisting(desired, existing) {
|
|
48
|
+
const libraries = desired.libraries.filter(lib => existing.has(lib));
|
|
49
|
+
const currentLibrary = desired.currentLibrary && existing.has(desired.currentLibrary) ? desired.currentLibrary : undefined;
|
|
50
|
+
const missing = referencedLibraries(desired).filter(lib => !existing.has(lib));
|
|
51
|
+
const notes = [];
|
|
52
|
+
if (missing.length > 0)
|
|
53
|
+
notes.push(`not found, left out of the library list: ${missing.join(", ")}`);
|
|
54
|
+
if (desired.invalid.length > 0)
|
|
55
|
+
notes.push(`invalid library name(s) ignored: ${desired.invalid.join(", ")}`);
|
|
56
|
+
return {
|
|
57
|
+
command: libraryListCommand(libraries, currentLibrary),
|
|
58
|
+
libraries,
|
|
59
|
+
currentLibrary,
|
|
60
|
+
...(notes.length ? { warning: `Configured library list: ${notes.join("; ")}` } : {})
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Warning for names in the setting that are not valid library names. */
|
|
64
|
+
export function invalidNamesWarning(desired) {
|
|
65
|
+
return desired.invalid.length > 0
|
|
66
|
+
? `Configured library list: invalid library name(s) ignored: ${desired.invalid.join(", ")}`
|
|
67
|
+
: undefined;
|
|
68
|
+
}
|
|
69
|
+
/** Rows from existingLibrariesSql to a set of names. */
|
|
70
|
+
export function libraryNamesFromRows(rows) {
|
|
71
|
+
return new Set(rows.map(row => String(row.OBJNAME ?? "").trim().toUpperCase()).filter(Boolean));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* qsh prefix that puts a library list in effect for the `system` commands that
|
|
75
|
+
* follow. Each `system` runs in its own job, which inherits the qsh job's
|
|
76
|
+
* library list as changed by the liblist builtin. Errors (a missing library)
|
|
77
|
+
* are discarded so they do not mix with the command's output.
|
|
78
|
+
*/
|
|
79
|
+
export function qshLibraryListPrefix(resolved, initialUserLibraries) {
|
|
80
|
+
const quote = (lib) => Tools.shellQuote(lib);
|
|
81
|
+
const parts = [];
|
|
82
|
+
if (resolved.libraries.length > 0) {
|
|
83
|
+
for (const lib of initialUserLibraries.filter(l => l && l !== "QSYS" && l !== "QTEMP")) {
|
|
84
|
+
parts.push(`liblist -d ${quote(lib)}`);
|
|
85
|
+
}
|
|
86
|
+
// liblist -a adds to the front, so add in reverse order.
|
|
87
|
+
for (const lib of [...resolved.libraries].reverse())
|
|
88
|
+
parts.push(`liblist -a ${quote(lib)}`);
|
|
89
|
+
}
|
|
90
|
+
if (resolved.currentLibrary)
|
|
91
|
+
parts.push(`liblist -c ${quote(resolved.currentLibrary)}`);
|
|
92
|
+
return parts.map(part => `${part} >/dev/null 2>&1`).join("; ");
|
|
93
|
+
}
|