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/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
+ }