mcp-for-i 0.1.8 → 0.1.10
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 +1 -0
- package/dist/index.js +58 -5
- package/dist/mcp/audit.js +339 -0
- package/dist/mcp/tools.js +1962 -90
- package/docs/capabilities.md +220 -0
- package/docs/overview.md +11 -10
- package/docs/tools.md +70 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -156,3 +156,4 @@ npm run release:patch -- --no-push
|
|
|
156
156
|
- Main repo: [ashishthomas2202/mcp-for-i](https://github.com/ashishthomas2202/mcp-for-i)
|
|
157
157
|
- Skills repo: [ashishthomas2202/mcp-for-i-skills](https://github.com/ashishthomas2202/mcp-for-i-skills)
|
|
158
158
|
- Docs: `docs/`
|
|
159
|
+
- Full capability guide: `docs/capabilities.md`
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import crypto from "crypto";
|
|
2
3
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
5
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -6,7 +7,8 @@ import { getTools, handleTool } from "./mcp/tools.js";
|
|
|
6
7
|
import { McpContext } from "./mcp/context.js";
|
|
7
8
|
import { log } from "./mcp/logger.js";
|
|
8
9
|
import { validateToolInput } from "./mcp/validation.js";
|
|
9
|
-
|
|
10
|
+
import { appendAuditRecord } from "./mcp/audit.js";
|
|
11
|
+
const server = new Server({ name: "mcp-for-i", version: "0.1.8" }, { capabilities: { tools: {} } });
|
|
10
12
|
const ctx = new McpContext();
|
|
11
13
|
const tools = getTools();
|
|
12
14
|
const toolMap = new Map(tools.map(tool => [tool.name, tool]));
|
|
@@ -15,6 +17,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
15
17
|
}));
|
|
16
18
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
17
19
|
const { name, arguments: args } = request.params;
|
|
20
|
+
const correlationId = crypto.randomUUID();
|
|
21
|
+
const startedAt = Date.now();
|
|
18
22
|
try {
|
|
19
23
|
const toolDef = toolMap.get(name);
|
|
20
24
|
if (!toolDef)
|
|
@@ -23,16 +27,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
23
27
|
if (errors.length > 0) {
|
|
24
28
|
throw new Error(`Invalid arguments: ${errors.join("; ")}`);
|
|
25
29
|
}
|
|
26
|
-
|
|
30
|
+
const redactedArgs = redactSensitive(args || {});
|
|
31
|
+
log("info", "tool.call", { name, correlationId, args: redactedArgs });
|
|
27
32
|
const result = await handleTool(ctx, name, args || {});
|
|
28
|
-
|
|
33
|
+
const durationMs = Date.now() - startedAt;
|
|
34
|
+
log("debug", "tool.result", { name, correlationId, durationMs });
|
|
35
|
+
await safeAppendAudit({
|
|
36
|
+
tool: name,
|
|
37
|
+
status: "ok",
|
|
38
|
+
args: redactedArgs,
|
|
39
|
+
connectionName: resolveConnectionName(name, args || {}, ctx.activeName),
|
|
40
|
+
approve: Boolean(args && typeof args === "object" && args.approve),
|
|
41
|
+
durationMs,
|
|
42
|
+
correlationId,
|
|
43
|
+
resultSummary: summarizeResult(result)
|
|
44
|
+
});
|
|
29
45
|
return result;
|
|
30
46
|
}
|
|
31
47
|
catch (err) {
|
|
32
|
-
|
|
48
|
+
const durationMs = Date.now() - startedAt;
|
|
49
|
+
const errorMessage = err?.message || String(err);
|
|
50
|
+
log("error", "tool.error", { name, correlationId, durationMs, error: errorMessage });
|
|
51
|
+
await safeAppendAudit({
|
|
52
|
+
tool: name,
|
|
53
|
+
status: "error",
|
|
54
|
+
args: redactSensitive(args || {}),
|
|
55
|
+
connectionName: resolveConnectionName(name, args || {}, ctx.activeName),
|
|
56
|
+
approve: Boolean(args && typeof args === "object" && args.approve),
|
|
57
|
+
durationMs,
|
|
58
|
+
correlationId,
|
|
59
|
+
error: errorMessage
|
|
60
|
+
});
|
|
33
61
|
return {
|
|
34
62
|
isError: true,
|
|
35
|
-
content: [{ type: "text", text:
|
|
63
|
+
content: [{ type: "text", text: errorMessage }]
|
|
36
64
|
};
|
|
37
65
|
}
|
|
38
66
|
});
|
|
@@ -57,3 +85,28 @@ function redactSensitive(input) {
|
|
|
57
85
|
}
|
|
58
86
|
return input;
|
|
59
87
|
}
|
|
88
|
+
function summarizeResult(result) {
|
|
89
|
+
const contentCount = Array.isArray(result?.content) ? result.content.length : 0;
|
|
90
|
+
return {
|
|
91
|
+
isError: Boolean(result?.isError),
|
|
92
|
+
contentCount,
|
|
93
|
+
hasStructuredContent: typeof result?.structuredContent !== "undefined"
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function resolveConnectionName(tool, args, activeName) {
|
|
97
|
+
if (args?.connectionName)
|
|
98
|
+
return String(args.connectionName);
|
|
99
|
+
if (tool === "ibmi.connect" && args?.name)
|
|
100
|
+
return String(args.name);
|
|
101
|
+
return activeName;
|
|
102
|
+
}
|
|
103
|
+
async function safeAppendAudit(input) {
|
|
104
|
+
if (process.env.MCP_FOR_I_AUDIT_ENABLED === "0")
|
|
105
|
+
return;
|
|
106
|
+
try {
|
|
107
|
+
await appendAuditRecord(input);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
log("warn", "audit.append.failed", { error: err?.message || String(err) });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { getConfigDir } from "../config/store.js";
|
|
5
|
+
let appendQueue = Promise.resolve();
|
|
6
|
+
export async function appendAuditRecord(input) {
|
|
7
|
+
const paths = resolveAuditPaths();
|
|
8
|
+
await fs.mkdir(paths.dir, { recursive: true });
|
|
9
|
+
let created;
|
|
10
|
+
appendQueue = appendQueue.then(async () => {
|
|
11
|
+
const prevHash = await readHeadHash(paths.headPath);
|
|
12
|
+
const nowIso = new Date().toISOString();
|
|
13
|
+
const redactedArgs = redactSensitive(input.args);
|
|
14
|
+
const argsHash = sha256Hex(stableStringify(redactedArgs));
|
|
15
|
+
const base = {
|
|
16
|
+
version: 1,
|
|
17
|
+
id: crypto.randomUUID(),
|
|
18
|
+
at: nowIso,
|
|
19
|
+
tool: input.tool,
|
|
20
|
+
status: input.status,
|
|
21
|
+
connectionName: input.connectionName,
|
|
22
|
+
approve: input.approve,
|
|
23
|
+
durationMs: input.durationMs,
|
|
24
|
+
correlationId: input.correlationId,
|
|
25
|
+
argsHash,
|
|
26
|
+
args: redactedArgs,
|
|
27
|
+
error: input.error,
|
|
28
|
+
resultSummary: input.resultSummary,
|
|
29
|
+
prevHash
|
|
30
|
+
};
|
|
31
|
+
const hash = sha256Hex(`${prevHash}|${stableStringify(base)}`);
|
|
32
|
+
const record = { ...base, hash };
|
|
33
|
+
await fs.appendFile(paths.logPath, `${JSON.stringify(record)}\n`, "utf8");
|
|
34
|
+
await fs.writeFile(paths.headPath, hash, "utf8");
|
|
35
|
+
created = record;
|
|
36
|
+
});
|
|
37
|
+
await appendQueue;
|
|
38
|
+
return created;
|
|
39
|
+
}
|
|
40
|
+
export async function listAuditRecords(filter = {}) {
|
|
41
|
+
const paths = resolveAuditPaths();
|
|
42
|
+
const limit = clampNumber(filter.limit, 200, 1, 5000);
|
|
43
|
+
const entries = await readAuditRecords(paths.logPath);
|
|
44
|
+
const sinceMs = filter.since ? Date.parse(filter.since) : Number.NaN;
|
|
45
|
+
const hasSince = Number.isFinite(sinceMs);
|
|
46
|
+
const filtered = entries.filter(entry => {
|
|
47
|
+
if (filter.tool && entry.tool !== filter.tool)
|
|
48
|
+
return false;
|
|
49
|
+
if (filter.status && entry.status !== filter.status)
|
|
50
|
+
return false;
|
|
51
|
+
if (filter.connectionName && (entry.connectionName || "") !== filter.connectionName)
|
|
52
|
+
return false;
|
|
53
|
+
if (filter.correlationId && entry.correlationId !== filter.correlationId)
|
|
54
|
+
return false;
|
|
55
|
+
if (hasSince && Date.parse(entry.at) < sinceMs)
|
|
56
|
+
return false;
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
const newestFirst = filtered.reverse().slice(0, limit);
|
|
60
|
+
return {
|
|
61
|
+
records: newestFirst,
|
|
62
|
+
total: filtered.length,
|
|
63
|
+
limit
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export async function verifyAuditChain() {
|
|
67
|
+
const paths = resolveAuditPaths();
|
|
68
|
+
const records = await readAuditRecords(paths.logPath);
|
|
69
|
+
let previous = "";
|
|
70
|
+
let failures = 0;
|
|
71
|
+
const failureDetails = [];
|
|
72
|
+
for (const record of records) {
|
|
73
|
+
if (record.prevHash !== previous) {
|
|
74
|
+
failures += 1;
|
|
75
|
+
failureDetails.push({ id: record.id, reason: "prevHash mismatch" });
|
|
76
|
+
previous = record.hash;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const base = {
|
|
80
|
+
version: record.version,
|
|
81
|
+
id: record.id,
|
|
82
|
+
at: record.at,
|
|
83
|
+
tool: record.tool,
|
|
84
|
+
status: record.status,
|
|
85
|
+
connectionName: record.connectionName,
|
|
86
|
+
approve: record.approve,
|
|
87
|
+
durationMs: record.durationMs,
|
|
88
|
+
correlationId: record.correlationId,
|
|
89
|
+
argsHash: record.argsHash,
|
|
90
|
+
args: record.args,
|
|
91
|
+
error: record.error,
|
|
92
|
+
resultSummary: record.resultSummary,
|
|
93
|
+
prevHash: record.prevHash
|
|
94
|
+
};
|
|
95
|
+
const expected = sha256Hex(`${record.prevHash}|${stableStringify(base)}`);
|
|
96
|
+
if (expected !== record.hash) {
|
|
97
|
+
failures += 1;
|
|
98
|
+
failureDetails.push({ id: record.id, reason: "hash mismatch" });
|
|
99
|
+
}
|
|
100
|
+
previous = record.hash;
|
|
101
|
+
}
|
|
102
|
+
const headHash = await readHeadHash(paths.headPath);
|
|
103
|
+
const headMatches = records.length === 0 ? headHash === "" : headHash === records[records.length - 1].hash;
|
|
104
|
+
if (!headMatches) {
|
|
105
|
+
failures += 1;
|
|
106
|
+
failureDetails.push({ id: "HEAD", reason: "head hash mismatch" });
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
ok: failures === 0,
|
|
110
|
+
records: records.length,
|
|
111
|
+
failures,
|
|
112
|
+
details: failureDetails.slice(0, 50),
|
|
113
|
+
headHash
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export async function exportAuditRecords(options) {
|
|
117
|
+
const paths = resolveAuditPaths();
|
|
118
|
+
const format = normalizeExportFormat(options.format);
|
|
119
|
+
const outputPath = path.resolve(String(options.outputPath));
|
|
120
|
+
const filter = options.filter || {};
|
|
121
|
+
const listing = await listAuditRecords({
|
|
122
|
+
...filter,
|
|
123
|
+
limit: filter.limit ?? 5000
|
|
124
|
+
});
|
|
125
|
+
const records = [...listing.records].reverse();
|
|
126
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
127
|
+
if (format === "json") {
|
|
128
|
+
await fs.writeFile(outputPath, JSON.stringify(records, null, 2), "utf8");
|
|
129
|
+
}
|
|
130
|
+
else if (format === "csv") {
|
|
131
|
+
const header = [
|
|
132
|
+
"id",
|
|
133
|
+
"at",
|
|
134
|
+
"tool",
|
|
135
|
+
"status",
|
|
136
|
+
"connectionName",
|
|
137
|
+
"approve",
|
|
138
|
+
"durationMs",
|
|
139
|
+
"correlationId",
|
|
140
|
+
"argsHash",
|
|
141
|
+
"prevHash",
|
|
142
|
+
"hash",
|
|
143
|
+
"error"
|
|
144
|
+
];
|
|
145
|
+
const lines = [header.join(",")];
|
|
146
|
+
for (const row of records) {
|
|
147
|
+
const values = [
|
|
148
|
+
row.id,
|
|
149
|
+
row.at,
|
|
150
|
+
row.tool,
|
|
151
|
+
row.status,
|
|
152
|
+
row.connectionName || "",
|
|
153
|
+
String(Boolean(row.approve)),
|
|
154
|
+
String(row.durationMs),
|
|
155
|
+
row.correlationId,
|
|
156
|
+
row.argsHash,
|
|
157
|
+
row.prevHash,
|
|
158
|
+
row.hash,
|
|
159
|
+
row.error || ""
|
|
160
|
+
];
|
|
161
|
+
lines.push(values.map(csvEscape).join(","));
|
|
162
|
+
}
|
|
163
|
+
await fs.writeFile(outputPath, `${lines.join("\n")}\n`, "utf8");
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const payload = records.map(row => JSON.stringify(row)).join("\n");
|
|
167
|
+
await fs.writeFile(outputPath, payload.length > 0 ? `${payload}\n` : "", "utf8");
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
outputPath,
|
|
172
|
+
format,
|
|
173
|
+
exported: records.length,
|
|
174
|
+
sourcePath: paths.logPath
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export async function purgeAuditRecords(options) {
|
|
178
|
+
const beforeIso = String(options.before || "").trim();
|
|
179
|
+
const cutoff = Date.parse(beforeIso);
|
|
180
|
+
if (!Number.isFinite(cutoff)) {
|
|
181
|
+
throw new Error(`Invalid before timestamp: ${options.before}`);
|
|
182
|
+
}
|
|
183
|
+
const paths = resolveAuditPaths();
|
|
184
|
+
await fs.mkdir(paths.dir, { recursive: true });
|
|
185
|
+
const records = await readAuditRecords(paths.logPath);
|
|
186
|
+
const keep = records.filter(record => Date.parse(record.at) >= cutoff);
|
|
187
|
+
const purgeCount = records.length - keep.length;
|
|
188
|
+
const dryRun = Boolean(options.dryRun);
|
|
189
|
+
if (dryRun || purgeCount === 0) {
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
dryRun,
|
|
193
|
+
before: new Date(cutoff).toISOString(),
|
|
194
|
+
total: records.length,
|
|
195
|
+
purged: purgeCount,
|
|
196
|
+
remaining: keep.length
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
await queueWrite(async () => {
|
|
200
|
+
const rebuilt = rebuildAuditChain(keep);
|
|
201
|
+
const payload = rebuilt.map(row => JSON.stringify(row)).join("\n");
|
|
202
|
+
await fs.writeFile(paths.logPath, payload.length > 0 ? `${payload}\n` : "", "utf8");
|
|
203
|
+
const headHash = rebuilt.length > 0 ? rebuilt[rebuilt.length - 1].hash : "";
|
|
204
|
+
await fs.writeFile(paths.headPath, headHash, "utf8");
|
|
205
|
+
});
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
dryRun: false,
|
|
209
|
+
before: new Date(cutoff).toISOString(),
|
|
210
|
+
total: records.length,
|
|
211
|
+
purged: purgeCount,
|
|
212
|
+
remaining: keep.length
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function resolveAuditPaths() {
|
|
216
|
+
const explicit = process.env.MCP_FOR_I_AUDIT_LOG_PATH;
|
|
217
|
+
const logPath = explicit && explicit.trim().length > 0
|
|
218
|
+
? path.resolve(explicit)
|
|
219
|
+
: path.join(getConfigDir(), "audit", "tool-audit.jsonl");
|
|
220
|
+
const dir = path.dirname(logPath);
|
|
221
|
+
return {
|
|
222
|
+
dir,
|
|
223
|
+
logPath,
|
|
224
|
+
headPath: path.join(dir, "tool-audit.head")
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async function readAuditRecords(logPath) {
|
|
228
|
+
try {
|
|
229
|
+
const raw = await fs.readFile(logPath, "utf8");
|
|
230
|
+
return raw
|
|
231
|
+
.split(/\r?\n/)
|
|
232
|
+
.map(line => line.trim())
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.map(line => JSON.parse(line));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function readHeadHash(headPath) {
|
|
241
|
+
try {
|
|
242
|
+
const raw = await fs.readFile(headPath, "utf8");
|
|
243
|
+
return raw.trim();
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return "";
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function queueWrite(work) {
|
|
250
|
+
appendQueue = appendQueue.then(async () => {
|
|
251
|
+
await work();
|
|
252
|
+
});
|
|
253
|
+
await appendQueue;
|
|
254
|
+
}
|
|
255
|
+
function rebuildAuditChain(records) {
|
|
256
|
+
let previous = "";
|
|
257
|
+
const rebuilt = [];
|
|
258
|
+
for (const record of records) {
|
|
259
|
+
const base = {
|
|
260
|
+
version: record.version,
|
|
261
|
+
id: record.id,
|
|
262
|
+
at: record.at,
|
|
263
|
+
tool: record.tool,
|
|
264
|
+
status: record.status,
|
|
265
|
+
connectionName: record.connectionName,
|
|
266
|
+
approve: record.approve,
|
|
267
|
+
durationMs: record.durationMs,
|
|
268
|
+
correlationId: record.correlationId,
|
|
269
|
+
argsHash: record.argsHash,
|
|
270
|
+
args: record.args,
|
|
271
|
+
error: record.error,
|
|
272
|
+
resultSummary: record.resultSummary,
|
|
273
|
+
prevHash: previous
|
|
274
|
+
};
|
|
275
|
+
const hash = sha256Hex(`${previous}|${stableStringify(base)}`);
|
|
276
|
+
const next = { ...base, hash };
|
|
277
|
+
rebuilt.push(next);
|
|
278
|
+
previous = hash;
|
|
279
|
+
}
|
|
280
|
+
return rebuilt;
|
|
281
|
+
}
|
|
282
|
+
function normalizeExportFormat(format) {
|
|
283
|
+
const normalized = String(format || "jsonl").trim().toLowerCase();
|
|
284
|
+
if (normalized === "json" || normalized === "csv" || normalized === "jsonl") {
|
|
285
|
+
return normalized;
|
|
286
|
+
}
|
|
287
|
+
throw new Error(`Unsupported export format: ${format}`);
|
|
288
|
+
}
|
|
289
|
+
function csvEscape(value) {
|
|
290
|
+
if (value.includes(",") || value.includes("\"") || value.includes("\n")) {
|
|
291
|
+
return `"${value.replace(/"/g, "\"\"")}"`;
|
|
292
|
+
}
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
295
|
+
function clampNumber(value, fallback, min, max) {
|
|
296
|
+
const n = Number(value);
|
|
297
|
+
if (!Number.isFinite(n))
|
|
298
|
+
return fallback;
|
|
299
|
+
if (n < min)
|
|
300
|
+
return min;
|
|
301
|
+
if (n > max)
|
|
302
|
+
return max;
|
|
303
|
+
return Math.floor(n);
|
|
304
|
+
}
|
|
305
|
+
function sha256Hex(value) {
|
|
306
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
307
|
+
}
|
|
308
|
+
function stableStringify(value) {
|
|
309
|
+
if (value === null || typeof value !== "object")
|
|
310
|
+
return JSON.stringify(value);
|
|
311
|
+
if (Array.isArray(value))
|
|
312
|
+
return `[${value.map(v => stableStringify(v)).join(",")}]`;
|
|
313
|
+
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
|
|
314
|
+
const props = entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
|
|
315
|
+
return `{${props.join(",")}}`;
|
|
316
|
+
}
|
|
317
|
+
function redactSensitive(input) {
|
|
318
|
+
const secrets = ["password", "passphrase", "secret", "token", "apikey", "apiKey", "authorization"];
|
|
319
|
+
if (Array.isArray(input)) {
|
|
320
|
+
return input.map(redactSensitive);
|
|
321
|
+
}
|
|
322
|
+
if (typeof input === "object" && input !== null) {
|
|
323
|
+
const out = {};
|
|
324
|
+
for (const [key, value] of Object.entries(input)) {
|
|
325
|
+
const lower = key.toLowerCase();
|
|
326
|
+
if (secrets.some(secret => lower.includes(secret.toLowerCase()))) {
|
|
327
|
+
out[key] = "***REDACTED***";
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
out[key] = redactSensitive(value);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
if (typeof input === "string" && input.length > 1000) {
|
|
336
|
+
return `${input.substring(0, 1000)}...(truncated)`;
|
|
337
|
+
}
|
|
338
|
+
return input;
|
|
339
|
+
}
|