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.
- package/README.md +53 -0
- package/dist/config/store.js +365 -0
- package/dist/controlplane/autostart.js +93 -0
- package/dist/controlplane/connectionService.js +79 -0
- package/dist/controlplane/index.js +120 -0
- package/dist/controlplane/runtimeService.js +263 -0
- package/dist/controlplane/server.js +180 -0
- package/dist/controlplane/ui.js +741 -0
- package/dist/ibmi/CompileTools.js +91 -0
- package/dist/ibmi/IBMiContent.js +386 -0
- package/dist/ibmi/LocalLanguageActions.js +183 -0
- package/dist/ibmi/Search.js +135 -0
- package/dist/ibmi/Tools.js +188 -0
- package/dist/ibmi/client.js +213 -0
- package/dist/ibmi/sourceDates.js +60 -0
- package/dist/ibmi/types.js +1 -0
- package/dist/ibmi/variables.js +53 -0
- package/dist/index.js +59 -0
- package/dist/mcp/context.js +235 -0
- package/dist/mcp/logger.js +73 -0
- package/dist/mcp/tools.js +1335 -0
- package/dist/mcp/validation.js +74 -0
- package/dist/security/credentialStore.js +42 -0
- package/docs/configuration.md +57 -0
- package/docs/examples.md +73 -0
- package/docs/installation.md +61 -0
- package/docs/overview.md +14 -0
- package/docs/security.md +8 -0
- package/docs/tools.md +105 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { getTools, handleTool } from "./mcp/tools.js";
|
|
6
|
+
import { McpContext } from "./mcp/context.js";
|
|
7
|
+
import { log } from "./mcp/logger.js";
|
|
8
|
+
import { validateToolInput } from "./mcp/validation.js";
|
|
9
|
+
const server = new Server({ name: "mcp-for-i", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
10
|
+
const ctx = new McpContext();
|
|
11
|
+
const tools = getTools();
|
|
12
|
+
const toolMap = new Map(tools.map(tool => [tool.name, tool]));
|
|
13
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
14
|
+
tools
|
|
15
|
+
}));
|
|
16
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
17
|
+
const { name, arguments: args } = request.params;
|
|
18
|
+
try {
|
|
19
|
+
const toolDef = toolMap.get(name);
|
|
20
|
+
if (!toolDef)
|
|
21
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
22
|
+
const errors = validateToolInput(toolDef.inputSchema, args || {});
|
|
23
|
+
if (errors.length > 0) {
|
|
24
|
+
throw new Error(`Invalid arguments: ${errors.join("; ")}`);
|
|
25
|
+
}
|
|
26
|
+
log("info", "tool.call", { name, args: redactSensitive(args || {}) });
|
|
27
|
+
const result = await handleTool(ctx, name, args || {});
|
|
28
|
+
log("debug", "tool.result", { name });
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
log("error", "tool.error", { name, error: err?.message || String(err) });
|
|
33
|
+
return {
|
|
34
|
+
isError: true,
|
|
35
|
+
content: [{ type: "text", text: err?.message || String(err) }]
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
const transport = new StdioServerTransport();
|
|
40
|
+
await server.connect(transport);
|
|
41
|
+
function redactSensitive(input) {
|
|
42
|
+
const secrets = new Set(["password", "passphrase", "secret", "token", "apikey", "apiKey", "authorization"]);
|
|
43
|
+
if (Array.isArray(input)) {
|
|
44
|
+
return input.map(redactSensitive);
|
|
45
|
+
}
|
|
46
|
+
if (typeof input === "object" && input !== null) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [key, value] of Object.entries(input)) {
|
|
49
|
+
if (secrets.has(key)) {
|
|
50
|
+
out[key] = "***REDACTED***";
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
out[key] = redactSensitive(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
return input;
|
|
59
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { ConfigStore } from "../config/store.js";
|
|
3
|
+
import { IBMiClient } from "../ibmi/client.js";
|
|
4
|
+
import { getPassword } from "../security/credentialStore.js";
|
|
5
|
+
const DEFAULT_SESSION_IDLE_MS = 30 * 60 * 1000;
|
|
6
|
+
const DEFAULT_CURSOR_TTL_MS = 5 * 60 * 1000;
|
|
7
|
+
export class McpContext {
|
|
8
|
+
store = new ConfigStore();
|
|
9
|
+
activeName;
|
|
10
|
+
sessions = new Map();
|
|
11
|
+
cursors = new Map();
|
|
12
|
+
sessionIdleMs = toPositiveInt(process.env.MCP_FOR_I_SESSION_IDLE_MS, DEFAULT_SESSION_IDLE_MS);
|
|
13
|
+
cursorTtlMs = toPositiveInt(process.env.MCP_FOR_I_SQL_CURSOR_TTL_MS, DEFAULT_CURSOR_TTL_MS);
|
|
14
|
+
async connect(connection, configOverride, policy) {
|
|
15
|
+
const baseSettings = await this.store.getSettings();
|
|
16
|
+
const config = {
|
|
17
|
+
name: connection.name,
|
|
18
|
+
host: connection.host,
|
|
19
|
+
port: connection.port,
|
|
20
|
+
username: connection.username,
|
|
21
|
+
...baseSettings,
|
|
22
|
+
...configOverride
|
|
23
|
+
};
|
|
24
|
+
const client = new IBMiClient(config);
|
|
25
|
+
await client.connect(connection);
|
|
26
|
+
await this.registerSession(connection.name, client, policy || defaultPolicy());
|
|
27
|
+
return client;
|
|
28
|
+
}
|
|
29
|
+
async connectByName(name) {
|
|
30
|
+
const existing = this.sessions.get(name);
|
|
31
|
+
if (existing) {
|
|
32
|
+
this.touchSession(name);
|
|
33
|
+
this.activeName = name;
|
|
34
|
+
return existing.client;
|
|
35
|
+
}
|
|
36
|
+
const stored = await this.store.getConnection(name);
|
|
37
|
+
if (!stored)
|
|
38
|
+
throw new Error(`Unknown connection: ${name}`);
|
|
39
|
+
const password = await getPassword(name);
|
|
40
|
+
const connection = {
|
|
41
|
+
name: stored.name,
|
|
42
|
+
host: stored.host,
|
|
43
|
+
port: stored.port,
|
|
44
|
+
username: stored.username,
|
|
45
|
+
password,
|
|
46
|
+
privateKeyPath: stored.privateKeyPath
|
|
47
|
+
};
|
|
48
|
+
const profile = resolveProfile(stored.currentProfile, stored.profiles);
|
|
49
|
+
const profileSettings = profileToSettings(profile);
|
|
50
|
+
const policy = normalizePolicy(stored.policy);
|
|
51
|
+
return this.connect(connection, { ...stored.settings, ...profileSettings }, policy);
|
|
52
|
+
}
|
|
53
|
+
ensureActive(name) {
|
|
54
|
+
const sessionName = name || this.activeName;
|
|
55
|
+
if (!sessionName)
|
|
56
|
+
throw new Error("Not connected");
|
|
57
|
+
const session = this.sessions.get(sessionName);
|
|
58
|
+
if (!session)
|
|
59
|
+
throw new Error(`Session ${sessionName} is not connected`);
|
|
60
|
+
this.touchSession(sessionName);
|
|
61
|
+
return session.client;
|
|
62
|
+
}
|
|
63
|
+
getPolicy(name) {
|
|
64
|
+
const sessionName = name || this.activeName;
|
|
65
|
+
if (!sessionName)
|
|
66
|
+
return defaultPolicy();
|
|
67
|
+
return this.sessions.get(sessionName)?.policy || defaultPolicy();
|
|
68
|
+
}
|
|
69
|
+
listSessions() {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
return Array.from(this.sessions.values()).map(session => ({
|
|
72
|
+
name: session.name,
|
|
73
|
+
active: session.name === this.activeName,
|
|
74
|
+
policy: session.policy.profile,
|
|
75
|
+
lastUsedAt: new Date(session.lastUsedAt).toISOString(),
|
|
76
|
+
expiresAt: new Date(session.expiresAt).toISOString(),
|
|
77
|
+
idleSecondsRemaining: Math.max(0, Math.floor((session.expiresAt - now) / 1000))
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
statusSession(name) {
|
|
81
|
+
const target = name || this.activeName;
|
|
82
|
+
if (!target)
|
|
83
|
+
return { connected: false };
|
|
84
|
+
const session = this.sessions.get(target);
|
|
85
|
+
if (!session)
|
|
86
|
+
return { connected: false, name: target };
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
return {
|
|
89
|
+
connected: true,
|
|
90
|
+
name: target,
|
|
91
|
+
active: target === this.activeName,
|
|
92
|
+
policy: session.policy.profile,
|
|
93
|
+
lastUsedAt: new Date(session.lastUsedAt).toISOString(),
|
|
94
|
+
expiresAt: new Date(session.expiresAt).toISOString(),
|
|
95
|
+
idleSecondsRemaining: Math.max(0, Math.floor((session.expiresAt - now) / 1000))
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
keepaliveSession(name) {
|
|
99
|
+
const target = name || this.activeName;
|
|
100
|
+
if (!target)
|
|
101
|
+
throw new Error("connectionName is required (no active session)");
|
|
102
|
+
this.touchSession(target);
|
|
103
|
+
return this.statusSession(target);
|
|
104
|
+
}
|
|
105
|
+
async terminateSession(name) {
|
|
106
|
+
const target = name || this.activeName;
|
|
107
|
+
if (!target)
|
|
108
|
+
throw new Error("connectionName is required (no active session)");
|
|
109
|
+
await this.disconnect(target);
|
|
110
|
+
}
|
|
111
|
+
async disconnect(name) {
|
|
112
|
+
if (name) {
|
|
113
|
+
const session = this.sessions.get(name);
|
|
114
|
+
if (!session)
|
|
115
|
+
return;
|
|
116
|
+
if (session.timeout)
|
|
117
|
+
clearTimeout(session.timeout);
|
|
118
|
+
await session.client.disconnect();
|
|
119
|
+
this.sessions.delete(name);
|
|
120
|
+
if (this.activeName === name) {
|
|
121
|
+
this.activeName = this.sessions.keys().next().value;
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const current = this.activeName;
|
|
126
|
+
if (current) {
|
|
127
|
+
await this.disconnect(current);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
createSqlCursor(rows, pageSize) {
|
|
131
|
+
const cursor = crypto.randomUUID();
|
|
132
|
+
const initial = rows.slice(pageSize);
|
|
133
|
+
if (initial.length === 0)
|
|
134
|
+
return { cursor: undefined };
|
|
135
|
+
this.cursors.set(cursor, {
|
|
136
|
+
rows: initial,
|
|
137
|
+
nextIndex: 0,
|
|
138
|
+
expiresAt: Date.now() + this.cursorTtlMs
|
|
139
|
+
});
|
|
140
|
+
return { cursor };
|
|
141
|
+
}
|
|
142
|
+
consumeSqlCursor(cursor, pageSize) {
|
|
143
|
+
const state = this.cursors.get(cursor);
|
|
144
|
+
if (!state)
|
|
145
|
+
throw new Error(`Unknown cursor: ${cursor}`);
|
|
146
|
+
if (state.expiresAt < Date.now()) {
|
|
147
|
+
this.cursors.delete(cursor);
|
|
148
|
+
throw new Error(`Cursor expired: ${cursor}`);
|
|
149
|
+
}
|
|
150
|
+
const start = state.nextIndex;
|
|
151
|
+
const end = start + pageSize;
|
|
152
|
+
const page = state.rows.slice(start, end);
|
|
153
|
+
state.nextIndex = end;
|
|
154
|
+
state.expiresAt = Date.now() + this.cursorTtlMs;
|
|
155
|
+
if (state.nextIndex >= state.rows.length) {
|
|
156
|
+
this.cursors.delete(cursor);
|
|
157
|
+
return { rows: page, cursor: undefined };
|
|
158
|
+
}
|
|
159
|
+
this.cursors.set(cursor, state);
|
|
160
|
+
return { rows: page, cursor };
|
|
161
|
+
}
|
|
162
|
+
async registerSession(name, client, policy) {
|
|
163
|
+
const existing = this.sessions.get(name);
|
|
164
|
+
if (existing) {
|
|
165
|
+
if (existing.timeout)
|
|
166
|
+
clearTimeout(existing.timeout);
|
|
167
|
+
await existing.client.disconnect();
|
|
168
|
+
}
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
this.sessions.set(name, {
|
|
171
|
+
name,
|
|
172
|
+
client,
|
|
173
|
+
policy,
|
|
174
|
+
lastUsedAt: now,
|
|
175
|
+
expiresAt: now + this.sessionIdleMs
|
|
176
|
+
});
|
|
177
|
+
this.activeName = name;
|
|
178
|
+
this.touchSession(name);
|
|
179
|
+
}
|
|
180
|
+
touchSession(name) {
|
|
181
|
+
const session = this.sessions.get(name);
|
|
182
|
+
if (!session)
|
|
183
|
+
throw new Error(`Session ${name} not found`);
|
|
184
|
+
session.lastUsedAt = Date.now();
|
|
185
|
+
session.expiresAt = session.lastUsedAt + this.sessionIdleMs;
|
|
186
|
+
if (session.timeout)
|
|
187
|
+
clearTimeout(session.timeout);
|
|
188
|
+
session.timeout = setTimeout(() => {
|
|
189
|
+
this.disconnect(name).catch(() => { });
|
|
190
|
+
}, this.sessionIdleMs);
|
|
191
|
+
this.sessions.set(name, session);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function resolveProfile(name, profiles) {
|
|
195
|
+
if (!name || !profiles)
|
|
196
|
+
return undefined;
|
|
197
|
+
return profiles.find(p => p.name === name);
|
|
198
|
+
}
|
|
199
|
+
function profileToSettings(profile) {
|
|
200
|
+
if (!profile)
|
|
201
|
+
return {};
|
|
202
|
+
return {
|
|
203
|
+
currentLibrary: profile.currentLibrary,
|
|
204
|
+
libraryList: profile.libraryList,
|
|
205
|
+
customVariables: profile.customVariables
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function toPositiveInt(input, fallback) {
|
|
209
|
+
const parsed = Number(input || "");
|
|
210
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
211
|
+
return fallback;
|
|
212
|
+
return Math.floor(parsed);
|
|
213
|
+
}
|
|
214
|
+
function defaultPolicy() {
|
|
215
|
+
return {
|
|
216
|
+
profile: "guarded",
|
|
217
|
+
requireApprovalFor: ["sql.write", "cl.run", "deploy.sync", "ifs.delete", "qsys.write"]
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function normalizePolicy(policy) {
|
|
221
|
+
if (!policy)
|
|
222
|
+
return defaultPolicy();
|
|
223
|
+
const profile = normalizeProfile(policy.profile);
|
|
224
|
+
return {
|
|
225
|
+
...defaultPolicy(),
|
|
226
|
+
...policy,
|
|
227
|
+
profile
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function normalizeProfile(profile) {
|
|
231
|
+
if (profile === "read-only" || profile === "power-user" || profile === "guarded") {
|
|
232
|
+
return profile;
|
|
233
|
+
}
|
|
234
|
+
return "guarded";
|
|
235
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
const levelOrder = {
|
|
3
|
+
error: 0,
|
|
4
|
+
warn: 1,
|
|
5
|
+
info: 2,
|
|
6
|
+
debug: 3
|
|
7
|
+
};
|
|
8
|
+
const argv = process.argv.slice(2);
|
|
9
|
+
const argLevel = argv.find(a => a.startsWith("--log-level="))?.split("=", 2)[1] || "";
|
|
10
|
+
const argFile = argv.find(a => a.startsWith("--log-file="))?.split("=", 2)[1] || "";
|
|
11
|
+
const argEnable = argv.includes("--log") || argv.includes("--log-stderr") || Boolean(argLevel) || Boolean(argFile);
|
|
12
|
+
const envLevel = process.env.MCP_FOR_I_LOG_LEVEL || "";
|
|
13
|
+
const envFile = process.env.MCP_FOR_I_LOG || "";
|
|
14
|
+
const envEnable = process.env.MCP_FOR_I_LOG_ENABLED === "1" || process.env.MCP_FOR_I_LOG_ENABLED === "true";
|
|
15
|
+
const envStderrFlag = process.env.MCP_FOR_I_LOG_STDERR;
|
|
16
|
+
const loggingEnabled = argEnable || envEnable || Boolean(envLevel) || Boolean(envFile) || (envStderrFlag && envStderrFlag !== "0");
|
|
17
|
+
const configuredLevel = (argLevel || envLevel || "info").toLowerCase();
|
|
18
|
+
const activeLevel = levelOrder[configuredLevel] !== undefined ? configuredLevel : "info";
|
|
19
|
+
const logFile = argFile || envFile || "";
|
|
20
|
+
const logToStderr = argv.includes("--log") || argv.includes("--log-stderr") || (envStderrFlag ? envStderrFlag !== "0" : false);
|
|
21
|
+
function shouldLog(level) {
|
|
22
|
+
return levelOrder[level] <= levelOrder[activeLevel];
|
|
23
|
+
}
|
|
24
|
+
function safeWrite(line) {
|
|
25
|
+
if (logFile) {
|
|
26
|
+
try {
|
|
27
|
+
fs.appendFileSync(logFile, line + "\n");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Ignore file errors; fall back to stderr
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (logToStderr || !logFile) {
|
|
34
|
+
process.stderr.write(line + "\n");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function redact(value) {
|
|
38
|
+
const secretKeys = ["password", "pass", "token", "secret", "key", "privatekey"];
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
return value.map(redact);
|
|
41
|
+
}
|
|
42
|
+
if (value && typeof value === "object") {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const [k, v] of Object.entries(value)) {
|
|
45
|
+
const lower = k.toLowerCase();
|
|
46
|
+
if (secretKeys.some(s => lower.includes(s))) {
|
|
47
|
+
out[k] = "***";
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
out[k] = redact(v);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === "string" && value.length > 500) {
|
|
56
|
+
return value.substring(0, 500) + "...(truncated)";
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
export function log(level, message, meta) {
|
|
61
|
+
if (!loggingEnabled)
|
|
62
|
+
return;
|
|
63
|
+
if (!shouldLog(level))
|
|
64
|
+
return;
|
|
65
|
+
const ts = new Date().toISOString();
|
|
66
|
+
const payload = meta ? ` ${JSON.stringify(redact(meta))}` : "";
|
|
67
|
+
if (!logToStderr && !logFile) {
|
|
68
|
+
// if logging is enabled but no output configured, default to stderr
|
|
69
|
+
process.stderr.write(`[${ts}] [${level.toUpperCase()}] ${message}${payload}\n`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
safeWrite(`[${ts}] [${level.toUpperCase()}] ${message}${payload}`);
|
|
73
|
+
}
|