mcp-for-i 0.1.13 → 0.3.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 +27 -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 +52 -11
- 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 +178 -11
- package/dist/controlplane/serverShared.js +25 -6
- package/dist/controlplane/serviceManager.js +147 -100
- package/dist/controlplane/ui.js +1783 -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 +334 -0
- package/dist/ibmi/tn5250/datastream.js +109 -0
- package/dist/ibmi/tn5250/ebcdic.js +93 -0
- package/dist/ibmi/tn5250/ebcdicTables.js +27 -0
- package/dist/ibmi/tn5250/index.js +6 -0
- package/dist/ibmi/tn5250/screen.js +1033 -0
- package/dist/ibmi/tn5250/telnet.js +186 -0
- package/dist/ibmi/variables.js +18 -7
- package/dist/index.js +79 -24
- 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 +800 -0
- package/dist/mcp/modules/tn5250App.js +194 -0
- package/dist/mcp/modules/tn5250Font.js +8 -0
- package/dist/mcp/modules/tn5250Live.js +221 -0
- package/dist/mcp/modules/tn5250LivePage.js +480 -0
- package/dist/mcp/policy.js +412 -0
- package/dist/mcp/tools.js +1719 -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 +264 -220
- package/docs/configuration.md +193 -60
- package/docs/examples.md +109 -73
- package/docs/overview.md +15 -15
- package/docs/security.md +101 -16
- package/docs/tools.md +367 -188
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@ From an npm install or a git checkout build, these commands are the supported co
|
|
|
50
50
|
```bash
|
|
51
51
|
mcp-for-i control
|
|
52
52
|
mcp-for-i control status
|
|
53
|
+
mcp-for-i control stop
|
|
54
|
+
mcp-for-i control restart
|
|
53
55
|
mcp-for-i control enable
|
|
54
56
|
mcp-for-i control disable
|
|
55
57
|
```
|
|
@@ -59,11 +61,15 @@ Equivalent direct control-plane binary commands:
|
|
|
59
61
|
```bash
|
|
60
62
|
mcp-for-i-control open
|
|
61
63
|
mcp-for-i-control status
|
|
64
|
+
mcp-for-i-control stop
|
|
65
|
+
mcp-for-i-control restart
|
|
62
66
|
mcp-for-i-control enable
|
|
63
67
|
mcp-for-i-control disable
|
|
64
68
|
mcp-for-i-control serve
|
|
65
69
|
```
|
|
66
70
|
|
|
71
|
+
`stop` shuts down the running control plane (it works for one started by `control`, `serve` or the background service). If the background service is enabled, the control plane starts again at the next login; `disable` turns that off. `restart` stops it and starts it again in the background.
|
|
72
|
+
|
|
67
73
|
Behavior by environment:
|
|
68
74
|
- Windows: `enable` registers a Startup entry.
|
|
69
75
|
- macOS: `enable` registers a LaunchAgent.
|
|
@@ -199,6 +205,27 @@ OTP is only needed when the npm account has two-factor authentication enabled fo
|
|
|
199
205
|
- Tool schemas are strict (`additionalProperties: false`) with runtime argument validation, including nested connection/action/filter/profile payloads.
|
|
200
206
|
- QSYS object names and source settings are validated before command execution to reduce interpolation and injection risk.
|
|
201
207
|
|
|
208
|
+
## Fast Source Investigation
|
|
209
|
+
|
|
210
|
+
The existing member tools remain compatible: an unchanged `ibmi.qsys.members.list` call still returns an array, and an unchanged `ibmi.qsys.members.read` call still returns source text. New callers can opt into faster, bounded workflows:
|
|
211
|
+
|
|
212
|
+
- `ibmi.session.bootstrap` returns connection, release, library-list, policy, transport, and capability information in one call.
|
|
213
|
+
- `ibmi.source.resolve` finds an exact member across likely source files without returning an entire source-file inventory.
|
|
214
|
+
- `ibmi.source.inspect` performs up to eight metadata, outline, search, range-read, and dependency operations over one consistent member snapshot.
|
|
215
|
+
- `ibmi.qsys.members.list` supports exact/prefix/contains/source-type/change-time filtering and keyset paging with `responseFormat: "page"`.
|
|
216
|
+
- `ibmi.qsys.members.read` supports ranges, context, metadata, SHA-256, and version checks with `responseFormat: "structured"`.
|
|
217
|
+
- `ibmi.qsys.members.write` accepts `expectedVersion` or `expectedSha256` to prevent a stale analysis from overwriting newer source.
|
|
218
|
+
|
|
219
|
+
Source-date reads use an ephemeral `QTEMP` alias in the same database job. If that path is unavailable, the server falls back to a complete UTF-8 `CPYTOSTMF` read; it never reconstructs a member from search matches.
|
|
220
|
+
|
|
221
|
+
## 5250 Green Screen
|
|
222
|
+
|
|
223
|
+
`ibmi.tn5250.*` gives the assistant a real 5250 session (a built-in TN5250 client, nothing to install on the IBM i). While a session is open the MCP server also serves a live view on `http://127.0.0.1:3981` (the link, with its token, is in every screen result and on the control plane's Runtime tab): you watch the screen the assistant is driving and can type and click in it yourself. Clients that support MCP Apps draw the same screen inside the chat. See [docs/tools.md](docs/tools.md#tn5250-notes).
|
|
224
|
+
|
|
225
|
+
## IBM i Documentation
|
|
226
|
+
|
|
227
|
+
`ibmi.docs.search` and `ibmi.docs.open` provide a release-aware, cached catalog of allowlisted official IBM documentation. The same catalog and topic content are exposed as MCP resources under `ibmi-docs://`. Documentation results include their official canonical URL, content version/checksum, and observation time. When local coverage is insufficient, search results explicitly recommend a web fallback instead of presenting cached material as exhaustive.
|
|
228
|
+
|
|
202
229
|
## Links
|
|
203
230
|
|
|
204
231
|
- Main repo: [ashishthomas2202/mcp-for-i](https://github.com/ashishthomas2202/mcp-for-i)
|
package/dist/config/store.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
|
+
import { constants as fsConstants } from "fs";
|
|
2
3
|
import path from "path";
|
|
3
4
|
import os from "os";
|
|
4
5
|
import { setPassword } from "../security/credentialStore.js";
|
|
6
|
+
import { readFileWithRetry, sleep, writeFileAtomic } from "../util/atomicWrite.js";
|
|
7
|
+
import { withFileLock } from "../util/fileLock.js";
|
|
5
8
|
const defaultSettings = {
|
|
6
9
|
readOnlyMode: false,
|
|
7
10
|
tempLibrary: "ILEDITOR",
|
|
@@ -22,138 +25,236 @@ const defaultSettings = {
|
|
|
22
25
|
sessionPingSeconds: 15,
|
|
23
26
|
sessionReconnectAttempts: 2
|
|
24
27
|
};
|
|
28
|
+
export class ConfigCorruptError extends Error {
|
|
29
|
+
configPath;
|
|
30
|
+
backupPath;
|
|
31
|
+
detail;
|
|
32
|
+
constructor(configPath, backupPath, detail) {
|
|
33
|
+
super(`Config file ${configPath} could not be parsed (${detail}). ` +
|
|
34
|
+
(backupPath ? `A copy was saved to ${backupPath}. ` : "") +
|
|
35
|
+
"The file was left unchanged; fix or remove it and try again.");
|
|
36
|
+
this.configPath = configPath;
|
|
37
|
+
this.backupPath = backupPath;
|
|
38
|
+
this.detail = detail;
|
|
39
|
+
this.name = "ConfigCorruptError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// configPath -> { raw content already backed up, backup path }
|
|
43
|
+
const backedUpCorruptContent = new Map();
|
|
44
|
+
const plaintextWarnings = new Set();
|
|
25
45
|
export class ConfigStore {
|
|
26
46
|
configDir;
|
|
27
47
|
configPath;
|
|
48
|
+
lockPath;
|
|
28
49
|
data = null;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this.configDir = getConfigDir();
|
|
50
|
+
constructor(options = {}) {
|
|
51
|
+
this.configDir = options.configDir || getConfigDir();
|
|
32
52
|
this.configPath = path.join(this.configDir, "config.json");
|
|
53
|
+
this.lockPath = `${this.configPath}.lock`;
|
|
33
54
|
}
|
|
55
|
+
getConfigPath() {
|
|
56
|
+
return this.configPath;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Reads the config from disk. Defaults are created only when the file does not
|
|
60
|
+
* exist. A file that exists but cannot be read or parsed is never overwritten:
|
|
61
|
+
* a backup copy is made and ConfigCorruptError is thrown.
|
|
62
|
+
*/
|
|
34
63
|
async load() {
|
|
35
|
-
await
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
loadedFromDisk = false;
|
|
43
|
-
this.data = { connections: [], settings: defaultSettings, actions: [] };
|
|
44
|
-
}
|
|
45
|
-
const migrated = await migrateConfigFile(this.data);
|
|
46
|
-
this.data = migrated.config;
|
|
47
|
-
if (!loadedFromDisk || migrated.changed) {
|
|
48
|
-
await this.save();
|
|
64
|
+
const disk = await this.readFromDisk();
|
|
65
|
+
if (disk.exists) {
|
|
66
|
+
const migrated = await migrateConfigFile(disk.parsed);
|
|
67
|
+
if (!migrated.changed) {
|
|
68
|
+
this.data = migrated.config;
|
|
69
|
+
return this.data;
|
|
70
|
+
}
|
|
49
71
|
}
|
|
72
|
+
// Missing file or a migration is needed: redo the read under the lock so a
|
|
73
|
+
// concurrent writer's changes are not overwritten.
|
|
74
|
+
await this.mutate(() => undefined);
|
|
50
75
|
return this.data;
|
|
51
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Writes the in-memory config as-is (last writer wins). Prefer the specific
|
|
79
|
+
* update methods or mutate(), which re-read the file under the lock first.
|
|
80
|
+
*/
|
|
52
81
|
async save() {
|
|
53
82
|
if (!this.data)
|
|
54
83
|
await this.load();
|
|
55
|
-
const payload =
|
|
56
|
-
await this.
|
|
57
|
-
const tempPath = `${this.configPath}.${process.pid}.${Date.now()}.tmp`;
|
|
58
|
-
await writeFileWithRetry(tempPath, payload);
|
|
59
|
-
try {
|
|
60
|
-
await fs.rename(tempPath, this.configPath);
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
await writeFileWithRetry(this.configPath, payload);
|
|
64
|
-
await fs.rm(tempPath, { force: true });
|
|
65
|
-
}
|
|
66
|
-
});
|
|
84
|
+
const payload = serializeConfig(this.data);
|
|
85
|
+
await withFileLock(this.lockPath, () => this.writePayload(payload));
|
|
67
86
|
}
|
|
68
87
|
async listConnections() {
|
|
69
88
|
const cfg = await this.load();
|
|
70
|
-
return cfg.connections;
|
|
89
|
+
return cfg.connections.map(publicConnection);
|
|
71
90
|
}
|
|
72
91
|
async getConnection(name) {
|
|
73
92
|
const cfg = await this.load();
|
|
74
|
-
|
|
93
|
+
const found = cfg.connections.find(c => c.name === name);
|
|
94
|
+
return found ? publicConnection(found) : undefined;
|
|
75
95
|
}
|
|
76
96
|
async upsertConnection(connection) {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
const { sanitized, password, persisted } = await sanitizeConnection(connection);
|
|
98
|
+
await this.mutate(cfg => {
|
|
99
|
+
const idx = cfg.connections.findIndex(c => c.name === sanitized.name);
|
|
100
|
+
if (idx >= 0) {
|
|
101
|
+
const merged = { ...cfg.connections[idx], ...sanitized };
|
|
102
|
+
// A legacy plaintext password stays only while it is still the password in
|
|
103
|
+
// use and the keychain could not take it.
|
|
104
|
+
if (password !== undefined && (persisted || merged.password !== password)) {
|
|
105
|
+
delete merged.password;
|
|
106
|
+
}
|
|
107
|
+
cfg.connections[idx] = merged;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
cfg.connections.push(sanitized);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
87
113
|
}
|
|
88
114
|
async deleteConnection(name) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
115
|
+
await this.mutate(cfg => {
|
|
116
|
+
cfg.connections = cfg.connections.filter(c => c.name !== name);
|
|
117
|
+
});
|
|
92
118
|
}
|
|
93
119
|
async getSettings() {
|
|
94
120
|
const cfg = await this.load();
|
|
95
121
|
return cfg.settings;
|
|
96
122
|
}
|
|
97
123
|
async updateSettings(update) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
124
|
+
await this.mutate(cfg => {
|
|
125
|
+
cfg.settings = { ...cfg.settings, ...update };
|
|
126
|
+
});
|
|
101
127
|
}
|
|
102
128
|
async listActions() {
|
|
103
129
|
const cfg = await this.load();
|
|
104
130
|
return cfg.actions || [];
|
|
105
131
|
}
|
|
106
132
|
async upsertAction(action) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
133
|
+
await this.mutate(cfg => {
|
|
134
|
+
const idx = cfg.actions.findIndex(a => a.name === action.name);
|
|
135
|
+
if (idx >= 0)
|
|
136
|
+
cfg.actions[idx] = action;
|
|
137
|
+
else
|
|
138
|
+
cfg.actions.push(action);
|
|
139
|
+
});
|
|
114
140
|
}
|
|
115
141
|
async deleteAction(name) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
142
|
+
await this.mutate(cfg => {
|
|
143
|
+
cfg.actions = cfg.actions.filter(a => a.name !== name);
|
|
144
|
+
});
|
|
119
145
|
}
|
|
120
146
|
async listProfiles(connectionName) {
|
|
121
147
|
const conn = await this.getConnection(connectionName);
|
|
122
148
|
return conn?.profiles || [];
|
|
123
149
|
}
|
|
124
150
|
async saveProfile(connectionName, profile) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
conn.profiles = profiles;
|
|
135
|
-
await this.upsertConnection(conn);
|
|
151
|
+
await this.mutateConnection(connectionName, conn => {
|
|
152
|
+
const profiles = conn.profiles || [];
|
|
153
|
+
const idx = profiles.findIndex(p => p.name === profile.name);
|
|
154
|
+
if (idx >= 0)
|
|
155
|
+
profiles[idx] = profile;
|
|
156
|
+
else
|
|
157
|
+
profiles.push(profile);
|
|
158
|
+
conn.profiles = profiles;
|
|
159
|
+
});
|
|
136
160
|
}
|
|
137
161
|
async deleteProfile(connectionName, profileName) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
await this.upsertConnection(conn);
|
|
162
|
+
await this.mutateConnection(connectionName, conn => {
|
|
163
|
+
conn.profiles = (conn.profiles || []).filter(p => p.name !== profileName);
|
|
164
|
+
if (conn.currentProfile === profileName) {
|
|
165
|
+
conn.currentProfile = undefined;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
146
168
|
}
|
|
147
169
|
async setCurrentProfile(connectionName, profileName) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
170
|
+
await this.mutateConnection(connectionName, conn => {
|
|
171
|
+
conn.currentProfile = profileName;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Read-modify-write under the cross-process lock: the file is re-read (and
|
|
176
|
+
* migrated) while the lock is held, `fn` changes it, and the result is written
|
|
177
|
+
* atomically. Concurrent callers in this and other processes are serialized,
|
|
178
|
+
* so no update is lost. `fn` must not call other ConfigStore update methods.
|
|
179
|
+
*/
|
|
180
|
+
async mutate(fn) {
|
|
181
|
+
return withFileLock(this.lockPath, async () => {
|
|
182
|
+
const disk = await this.readFromDisk();
|
|
183
|
+
const migrated = await migrateConfigFile(disk.exists ? disk.parsed : undefined);
|
|
184
|
+
const cfg = migrated.config;
|
|
185
|
+
const result = await fn(cfg);
|
|
186
|
+
const payload = serializeConfig(cfg);
|
|
187
|
+
if (!disk.exists || payload !== disk.raw) {
|
|
188
|
+
await this.writePayload(payload);
|
|
189
|
+
}
|
|
190
|
+
this.data = cfg;
|
|
191
|
+
return result;
|
|
192
|
+
});
|
|
153
193
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
194
|
+
async mutateConnection(connectionName, fn) {
|
|
195
|
+
await this.mutate(cfg => {
|
|
196
|
+
const conn = cfg.connections.find(c => c.name === connectionName);
|
|
197
|
+
if (!conn)
|
|
198
|
+
throw new Error(`Connection ${connectionName} not found`);
|
|
199
|
+
fn(conn);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async readFromDisk() {
|
|
203
|
+
let raw;
|
|
204
|
+
try {
|
|
205
|
+
raw = await readFileWithRetry(this.configPath);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (err?.code === "ENOENT")
|
|
209
|
+
return { exists: false };
|
|
210
|
+
throw new Error(`Config file ${this.configPath} could not be read (${err?.code || err?.message || String(err)}); it was left unchanged.`);
|
|
211
|
+
}
|
|
212
|
+
if (raw.trim().length === 0) {
|
|
213
|
+
// An empty file holds nothing that could be lost; treat it like a missing one.
|
|
214
|
+
return { exists: false };
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
return { exists: true, raw, parsed: JSON.parse(stripBom(raw)) };
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// A writer that does not use atomic renames (an older version) may be mid-write.
|
|
221
|
+
await sleep(50);
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
raw = await readFileWithRetry(this.configPath);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
if (err?.code === "ENOENT")
|
|
228
|
+
return { exists: false };
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
return { exists: true, raw, parsed: JSON.parse(stripBom(raw)) };
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
236
|
+
const backupPath = await this.backupCorruptFile(raw);
|
|
237
|
+
throw new ConfigCorruptError(this.configPath, backupPath, detail);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async backupCorruptFile(raw) {
|
|
241
|
+
const previous = backedUpCorruptContent.get(this.configPath);
|
|
242
|
+
if (previous && previous.raw === raw)
|
|
243
|
+
return previous.backupPath;
|
|
244
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
245
|
+
const backupPath = `${this.configPath}.corrupt-${stamp}`;
|
|
246
|
+
try {
|
|
247
|
+
await fs.copyFile(this.configPath, backupPath, fsConstants.COPYFILE_EXCL);
|
|
248
|
+
backedUpCorruptContent.set(this.configPath, { raw, backupPath });
|
|
249
|
+
return backupPath;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async writePayload(payload) {
|
|
256
|
+
await fs.mkdir(this.configDir, { recursive: true });
|
|
257
|
+
await writeFileAtomic(this.configPath, payload, { mode: 0o600 });
|
|
157
258
|
}
|
|
158
259
|
}
|
|
159
260
|
export function getConfigDir() {
|
|
@@ -166,6 +267,16 @@ export function getConfigDir() {
|
|
|
166
267
|
}
|
|
167
268
|
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "mcp-for-i");
|
|
168
269
|
}
|
|
270
|
+
function serializeConfig(cfg) {
|
|
271
|
+
return JSON.stringify(cfg, null, 2);
|
|
272
|
+
}
|
|
273
|
+
function stripBom(raw) {
|
|
274
|
+
return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
275
|
+
}
|
|
276
|
+
function publicConnection(connection) {
|
|
277
|
+
const { password: _password, ...rest } = connection;
|
|
278
|
+
return rest;
|
|
279
|
+
}
|
|
169
280
|
async function migrateConfigFile(input) {
|
|
170
281
|
let changed = false;
|
|
171
282
|
const root = isPlainObject(input) ? input : {};
|
|
@@ -218,9 +329,24 @@ async function normalizeConnections(value) {
|
|
|
218
329
|
changed = true;
|
|
219
330
|
seenNames.add(uniqueName);
|
|
220
331
|
const password = toNonEmptyString(entry.password);
|
|
332
|
+
let keepPlaintext = false;
|
|
221
333
|
if (password) {
|
|
222
|
-
|
|
223
|
-
|
|
334
|
+
// The plaintext is dropped only once the keychain confirms it holds the password.
|
|
335
|
+
let persistent = false;
|
|
336
|
+
let reason;
|
|
337
|
+
try {
|
|
338
|
+
persistent = (await setPassword(uniqueName, password, { keepExistingSessionValue: true })).persistent;
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
reason = err instanceof Error ? err.message : String(err);
|
|
342
|
+
}
|
|
343
|
+
if (persistent) {
|
|
344
|
+
changed = true;
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
keepPlaintext = true;
|
|
348
|
+
warnPlaintextKept(uniqueName, reason);
|
|
349
|
+
}
|
|
224
350
|
}
|
|
225
351
|
const host = toNonEmptyString(entry.host) || "";
|
|
226
352
|
const username = toNonEmptyString(entry.username) || "";
|
|
@@ -255,6 +381,8 @@ async function normalizeConnections(value) {
|
|
|
255
381
|
normalized.readyTimeout = readyTimeout;
|
|
256
382
|
if (sshDebug !== undefined)
|
|
257
383
|
normalized.sshDebug = sshDebug;
|
|
384
|
+
if (keepPlaintext)
|
|
385
|
+
normalized.password = password;
|
|
258
386
|
if (JSON.stringify(normalized) !== JSON.stringify(entry)) {
|
|
259
387
|
changed = true;
|
|
260
388
|
}
|
|
@@ -262,6 +390,14 @@ async function normalizeConnections(value) {
|
|
|
262
390
|
}
|
|
263
391
|
return { connections, changed };
|
|
264
392
|
}
|
|
393
|
+
function warnPlaintextKept(connectionName, reason) {
|
|
394
|
+
if (plaintextWarnings.has(connectionName))
|
|
395
|
+
return;
|
|
396
|
+
plaintextWarnings.add(connectionName);
|
|
397
|
+
process.stderr.write(`[mcp-for-i] warning: no persistent keychain is available, so the plaintext password for connection ` +
|
|
398
|
+
`"${connectionName}" was left in config.json${reason ? ` (${reason})` : ""}. ` +
|
|
399
|
+
"It will be moved to the keychain once one is available.\n");
|
|
400
|
+
}
|
|
265
401
|
function normalizeSettings(value) {
|
|
266
402
|
if (!isPlainObject(value)) {
|
|
267
403
|
return { value: { ...defaultSettings }, changed: true };
|
|
@@ -298,19 +434,23 @@ function normalizePolicy(value) {
|
|
|
298
434
|
delete policy.allowPaths;
|
|
299
435
|
if (!Array.isArray(policy.denyPaths))
|
|
300
436
|
delete policy.denyPaths;
|
|
437
|
+
if (policy.approvalMode !== "prompt" && policy.approvalMode !== "agent")
|
|
438
|
+
delete policy.approvalMode;
|
|
301
439
|
return policy;
|
|
302
440
|
}
|
|
303
441
|
async function sanitizeConnection(connection) {
|
|
304
442
|
const raw = connection;
|
|
305
443
|
const password = toNonEmptyString(raw.password);
|
|
444
|
+
let persisted = false;
|
|
306
445
|
if (password) {
|
|
307
|
-
await setPassword(connection.name, password);
|
|
446
|
+
persisted = (await setPassword(connection.name, password)).persistent;
|
|
308
447
|
}
|
|
309
448
|
const { password: _password, storePassword: _storePassword, ...rest } = raw;
|
|
310
449
|
const normalizedPort = normalizePort(rest.port);
|
|
311
450
|
return {
|
|
312
|
-
...rest,
|
|
313
|
-
|
|
451
|
+
sanitized: { ...rest, port: normalizedPort },
|
|
452
|
+
password,
|
|
453
|
+
persisted
|
|
314
454
|
};
|
|
315
455
|
}
|
|
316
456
|
function uniqueConnectionName(baseName, seen) {
|
|
@@ -346,23 +486,3 @@ function toNonEmptyString(value) {
|
|
|
346
486
|
function isPlainObject(value) {
|
|
347
487
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
348
488
|
}
|
|
349
|
-
async function ensureDir(dir) {
|
|
350
|
-
await fs.mkdir(dir, { recursive: true });
|
|
351
|
-
}
|
|
352
|
-
async function writeFileWithRetry(filePath, content, retries = 5, delayMs = 40) {
|
|
353
|
-
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
354
|
-
try {
|
|
355
|
-
await fs.writeFile(filePath, content, "utf8");
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
catch (err) {
|
|
359
|
-
const transient = err?.code === "EBUSY" || err?.code === "EPERM";
|
|
360
|
-
if (!transient || attempt === retries)
|
|
361
|
-
throw err;
|
|
362
|
-
await wait(delayMs * (attempt + 1));
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
async function wait(ms) {
|
|
367
|
-
await new Promise(resolve => setTimeout(resolve, ms));
|
|
368
|
-
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { getConfigDir } from "../config/store.js";
|
|
5
|
+
/** Header the Web UI and the CLI send with every control-plane API call. */
|
|
6
|
+
export const CONTROL_TOKEN_HEADER = "X-MCP-For-I-Token";
|
|
7
|
+
const TOKEN_FILE = "controlplane.token";
|
|
8
|
+
const TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
|
9
|
+
export function getControlTokenPath(configDir = getConfigDir()) {
|
|
10
|
+
return path.join(configDir, TOKEN_FILE);
|
|
11
|
+
}
|
|
12
|
+
/** Reads the per-install token, or returns undefined when none exists yet. */
|
|
13
|
+
export function readControlToken(configDir = getConfigDir()) {
|
|
14
|
+
try {
|
|
15
|
+
const raw = fs.readFileSync(getControlTokenPath(configDir), "utf8").trim();
|
|
16
|
+
return TOKEN_PATTERN.test(raw) ? raw : undefined;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Returns the per-install token, creating it on first use. The file is written
|
|
24
|
+
* with mode 0600 (owner only) where the platform supports it.
|
|
25
|
+
*/
|
|
26
|
+
export function ensureControlToken(configDir = getConfigDir()) {
|
|
27
|
+
const existing = readControlToken(configDir);
|
|
28
|
+
if (existing)
|
|
29
|
+
return existing;
|
|
30
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
31
|
+
const tokenPath = getControlTokenPath(configDir);
|
|
32
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
33
|
+
try {
|
|
34
|
+
fs.writeFileSync(tokenPath, `${token}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
35
|
+
return token;
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err?.code !== "EEXIST")
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
// Another process created it first, or the existing file is unreadable/invalid.
|
|
42
|
+
const raced = readControlToken(configDir);
|
|
43
|
+
if (raced)
|
|
44
|
+
return raced;
|
|
45
|
+
fs.writeFileSync(tokenPath, `${token}\n`, { encoding: "utf8", mode: 0o600 });
|
|
46
|
+
try {
|
|
47
|
+
fs.chmodSync(tokenPath, 0o600);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// best effort on platforms without POSIX modes
|
|
51
|
+
}
|
|
52
|
+
return token;
|
|
53
|
+
}
|
|
54
|
+
/** Constant-time token comparison. */
|
|
55
|
+
export function tokensMatch(expected, provided) {
|
|
56
|
+
if (typeof provided !== "string" || !provided)
|
|
57
|
+
return false;
|
|
58
|
+
const a = Buffer.from(expected, "utf8");
|
|
59
|
+
const b = Buffer.from(provided.trim(), "utf8");
|
|
60
|
+
if (a.length !== b.length)
|
|
61
|
+
return false;
|
|
62
|
+
return crypto.timingSafeEqual(a, b);
|
|
63
|
+
}
|
|
64
|
+
/** Hostnames the control plane answers to (blocks DNS rebinding). */
|
|
65
|
+
export function isAllowedHostHeader(hostHeader, port, extraHosts = []) {
|
|
66
|
+
if (!hostHeader)
|
|
67
|
+
return false;
|
|
68
|
+
const parsed = parseHostPort(hostHeader.trim().toLowerCase());
|
|
69
|
+
if (!parsed)
|
|
70
|
+
return false;
|
|
71
|
+
const allowed = new Set(["localhost", "127.0.0.1", "[::1]", ...extraHosts.map(normalizeHostName)]);
|
|
72
|
+
if (!allowed.has(parsed.host))
|
|
73
|
+
return false;
|
|
74
|
+
const effectivePort = parsed.port ?? 80;
|
|
75
|
+
return effectivePort === port;
|
|
76
|
+
}
|
|
77
|
+
/** An Origin header, when present, must point at this same local server. */
|
|
78
|
+
export function isAllowedOrigin(origin, port, extraHosts = []) {
|
|
79
|
+
if (origin === undefined)
|
|
80
|
+
return true;
|
|
81
|
+
let url;
|
|
82
|
+
try {
|
|
83
|
+
url = new URL(origin);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (url.protocol !== "http:")
|
|
89
|
+
return false;
|
|
90
|
+
const host = url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
|
91
|
+
return isAllowedHostHeader(host, port, extraHosts);
|
|
92
|
+
}
|
|
93
|
+
function parseHostPort(value) {
|
|
94
|
+
let host;
|
|
95
|
+
let portText;
|
|
96
|
+
if (value.startsWith("[")) {
|
|
97
|
+
const end = value.indexOf("]");
|
|
98
|
+
if (end < 0)
|
|
99
|
+
return undefined;
|
|
100
|
+
host = value.slice(0, end + 1);
|
|
101
|
+
const rest = value.slice(end + 1);
|
|
102
|
+
if (rest) {
|
|
103
|
+
if (!rest.startsWith(":"))
|
|
104
|
+
return undefined;
|
|
105
|
+
portText = rest.slice(1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const parts = value.split(":");
|
|
110
|
+
if (parts.length > 2)
|
|
111
|
+
return undefined;
|
|
112
|
+
host = parts[0];
|
|
113
|
+
portText = parts[1];
|
|
114
|
+
}
|
|
115
|
+
if (!host)
|
|
116
|
+
return undefined;
|
|
117
|
+
if (portText === undefined)
|
|
118
|
+
return { host };
|
|
119
|
+
if (!/^\d{1,5}$/.test(portText))
|
|
120
|
+
return undefined;
|
|
121
|
+
return { host, port: Number(portText) };
|
|
122
|
+
}
|
|
123
|
+
function normalizeHostName(host) {
|
|
124
|
+
const lower = host.trim().toLowerCase();
|
|
125
|
+
if (lower.includes(":") && !lower.startsWith("["))
|
|
126
|
+
return `[${lower}]`;
|
|
127
|
+
return lower;
|
|
128
|
+
}
|