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,685 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { getConfigDir } from "../config/store.js";
|
|
5
|
+
/** Releases with an IBM Docs collection. 7.1 and earlier were retired from IBM Docs. */
|
|
6
|
+
export const SUPPORTED_DOC_RELEASES = ["7.2", "7.3", "7.4", "7.5", "7.6"];
|
|
7
|
+
const CURRENT_RELEASES = ["7.4", "7.5", "7.6"];
|
|
8
|
+
const OLDER_RELEASES = ["7.2", "7.3"];
|
|
9
|
+
const MAX_BODY_BYTES = 12 * 1024 * 1024;
|
|
10
|
+
const MAX_REDIRECTS = 3;
|
|
11
|
+
const DEFAULT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
12
|
+
const DEFAULT_SEEDS = [
|
|
13
|
+
...OLDER_RELEASES.flatMap(release => [
|
|
14
|
+
{
|
|
15
|
+
title: `IBM i ${release} documentation overview`,
|
|
16
|
+
topic: "documentation-overview-whats-new",
|
|
17
|
+
categories: ["overview", "programming", "database", "systems-management"],
|
|
18
|
+
keywords: ["IBM i", "RPG", "CL", "SQL", "QSYS2", "IFS", "API", "security", "database", "system administration"]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
title: `IBM i ${release} PDF files and manuals`,
|
|
22
|
+
topic: "documentation-pdf-files-manuals",
|
|
23
|
+
categories: ["manuals", "programming", "database", "systems-management"],
|
|
24
|
+
keywords: ["manual", "reference", "RPG", "COBOL", "CL", "SQL", "API", "ILE", "DDS", "backup", "security"]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
title: `IBM i ${release} CL command finder`,
|
|
28
|
+
topic: "language-cl-command-finder",
|
|
29
|
+
categories: ["programming", "cl", "commands"],
|
|
30
|
+
keywords: ["CL", "command", "CRTLIB", "CRTPGM", "DSPJOB", "WRKACTJOB", "QCMDEXC"]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
title: `IBM i ${release} APIs overview`,
|
|
34
|
+
topic: "interfaces-apis-overview",
|
|
35
|
+
categories: ["programming", "apis"],
|
|
36
|
+
keywords: ["API", "QUSRMBRD", "QUSLJOB", "QUSLOBJ", "QUSRSPLA", "user space", "service program"]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
title: `IBM i ${release} ILE RPG Reference`,
|
|
40
|
+
topic: "rpg-ile-reference",
|
|
41
|
+
categories: ["programming", "rpg", "ile"],
|
|
42
|
+
keywords: ["RPGLE", "RPG IV", "free form", "operation codes", "built-in functions", "subprocedure", "prototype"]
|
|
43
|
+
}
|
|
44
|
+
].map(seed => ({
|
|
45
|
+
title: seed.title,
|
|
46
|
+
url: `https://www.ibm.com/docs/en/i/${release}.0?topic=${seed.topic}`,
|
|
47
|
+
release,
|
|
48
|
+
categories: seed.categories,
|
|
49
|
+
keywords: seed.keywords
|
|
50
|
+
}))),
|
|
51
|
+
...CURRENT_RELEASES.map(release => ({
|
|
52
|
+
title: `IBM i ${release} documentation overview`,
|
|
53
|
+
url: `https://www.ibm.com/docs/en/i/${release}.0?topic=documentation-overview-whats-new`,
|
|
54
|
+
release,
|
|
55
|
+
categories: ["overview", "programming", "database", "systems-management"],
|
|
56
|
+
keywords: ["IBM i", "RPG", "CL", "SQL", "QSYS2", "IFS", "API", "security", "database", "performance", "system administration"]
|
|
57
|
+
})),
|
|
58
|
+
...CURRENT_RELEASES.map(release => ({
|
|
59
|
+
title: `IBM i ${release} PDF files and manuals`,
|
|
60
|
+
url: `https://www.ibm.com/docs/en/i/${release}.0?topic=documentation-pdf-files-manuals`,
|
|
61
|
+
release,
|
|
62
|
+
categories: ["manuals", "programming", "database", "systems-management"],
|
|
63
|
+
keywords: ["manual", "reference", "RPG", "COBOL", "CL", "SQL", "API", "ILE", "DDS", "backup", "security"]
|
|
64
|
+
})),
|
|
65
|
+
...CURRENT_RELEASES.flatMap(release => [
|
|
66
|
+
{
|
|
67
|
+
title: `IBM i ${release} documentation site map`,
|
|
68
|
+
topic: "documentation-site-map",
|
|
69
|
+
categories: ["catalog", "programming", "database", "security", "systems-management"],
|
|
70
|
+
keywords: ["site map", "RPG", "COBOL", "CL", "SQL", "database", "IFS", "security", "backup", "work management"]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
title: `IBM i ${release} ILE RPG Reference`,
|
|
74
|
+
topic: "rpg-ile-reference",
|
|
75
|
+
categories: ["programming", "rpg", "ile"],
|
|
76
|
+
keywords: ["RPGLE", "RPG IV", "free form", "operation codes", "built-in functions", "subprocedure", "prototype"]
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
title: `IBM i ${release} CL command finder`,
|
|
80
|
+
topic: "language-cl-command-finder",
|
|
81
|
+
categories: ["programming", "cl", "commands"],
|
|
82
|
+
keywords: ["CL", "command", "CRTLIB", "CRTPGM", "DSPJOB", "WRKACTJOB", "QCMDEXC"]
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
title: `IBM i ${release} APIs overview`,
|
|
86
|
+
topic: "interfaces-apis-overview",
|
|
87
|
+
categories: ["programming", "apis"],
|
|
88
|
+
keywords: ["API", "QUSRMBRD", "QUSLJOB", "QUSLOBJ", "QUSRSPLA", "user space", "service program"]
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
title: `IBM i ${release} IBM i Services catalog`,
|
|
92
|
+
topic: "services-info-table",
|
|
93
|
+
categories: ["database", "sql", "services"],
|
|
94
|
+
keywords: ["QSYS2", "SYSTOOLS", "SQL services", "SERVICES_INFO", "table function", "view", "PTF"]
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
title: `IBM i ${release} security reference`,
|
|
98
|
+
topic: "security-reference",
|
|
99
|
+
categories: ["security", "systems-management"],
|
|
100
|
+
keywords: ["authority", "user profile", "authorization list", "audit journal", "QSECURITY", "object security"]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
title: `IBM i ${release} database query optimizer`,
|
|
104
|
+
topic: "performance-optimizer",
|
|
105
|
+
categories: ["database", "sql", "performance"],
|
|
106
|
+
keywords: ["optimizer", "access plan", "index", "SQL performance", "query engine", "Visual Explain"]
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
title: `IBM i ${release} integrated file system CL commands`,
|
|
110
|
+
topic: "system-accessing-using-cl-commands",
|
|
111
|
+
categories: ["ifs", "commands", "systems-management"],
|
|
112
|
+
keywords: ["IFS", "integrated file system", "ADDLNK", "ADDMFS", "WRKLNK", "CPYTOSTMF", "CPYFRMSTMF"]
|
|
113
|
+
}
|
|
114
|
+
].map(seed => ({
|
|
115
|
+
title: seed.title,
|
|
116
|
+
url: `https://www.ibm.com/docs/en/i/${release}.0?topic=${seed.topic}`,
|
|
117
|
+
release,
|
|
118
|
+
categories: seed.categories,
|
|
119
|
+
keywords: seed.keywords
|
|
120
|
+
})))
|
|
121
|
+
];
|
|
122
|
+
export class IbmDocsService {
|
|
123
|
+
cache = new Map();
|
|
124
|
+
inflight = new Map();
|
|
125
|
+
seeds;
|
|
126
|
+
cacheDir;
|
|
127
|
+
httpFetch;
|
|
128
|
+
cacheTtlMs;
|
|
129
|
+
now;
|
|
130
|
+
cacheDirReady;
|
|
131
|
+
cacheLoaded = false;
|
|
132
|
+
constructor(options = {}) {
|
|
133
|
+
this.cacheDir = options.cacheDir || process.env.MCP_FOR_I_IBM_DOCS_CACHE_DIR || path.join(getConfigDir(), "ibm-docs");
|
|
134
|
+
this.httpFetch = options.fetch || fetch;
|
|
135
|
+
this.cacheTtlMs = resolveCacheTtl(options.cacheTtlMs);
|
|
136
|
+
this.now = options.now || Date.now;
|
|
137
|
+
this.seeds = [...DEFAULT_SEEDS, ...loadExtraSeeds(), ...(options.seeds || [])]
|
|
138
|
+
.map(seed => ({ ...seed, url: validateOfficialUrl(seed.url).toString() }))
|
|
139
|
+
.filter((seed, index, all) => all.findIndex(item => item.url === seed.url) === index);
|
|
140
|
+
}
|
|
141
|
+
async search(options) {
|
|
142
|
+
const query = String(options.query || "").trim();
|
|
143
|
+
if (!query)
|
|
144
|
+
throw new Error("query is required");
|
|
145
|
+
const limit = Math.max(1, Math.min(50, Math.floor(options.limit || 10)));
|
|
146
|
+
const offset = options.cursor ? decodeCursor(options.cursor) : 0;
|
|
147
|
+
await this.loadCachedDocuments();
|
|
148
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
149
|
+
const releaseSet = new Set((options.releases || []).map(normalizeRelease));
|
|
150
|
+
const categorySet = new Set((options.categories || []).map(value => value.toLowerCase()));
|
|
151
|
+
const refreshWarnings = [];
|
|
152
|
+
const unsupportedReleases = [...releaseSet].filter(release => !SUPPORTED_DOC_RELEASES.includes(release));
|
|
153
|
+
if (unsupportedReleases.length) {
|
|
154
|
+
refreshWarnings.push(`IBM Docs has no collection for IBM i ${unsupportedReleases.join(", ")}; supported releases are ${SUPPORTED_DOC_RELEASES.join(", ")}. ` +
|
|
155
|
+
"Older manuals may only exist as PDFs or Redbooks.");
|
|
156
|
+
}
|
|
157
|
+
if (options.freshness === "prefer-live") {
|
|
158
|
+
const refreshCandidates = this.seeds.flatMap(seed => {
|
|
159
|
+
if (releaseSet.size && !releaseSet.has(normalizeRelease(seed.release)))
|
|
160
|
+
return [];
|
|
161
|
+
if (categorySet.size && !seed.categories.some(category => categorySet.has(category.toLowerCase())))
|
|
162
|
+
return [];
|
|
163
|
+
const catalog = `${seed.title} ${seed.keywords.join(" ")} ${seed.categories.join(" ")}`.toLowerCase();
|
|
164
|
+
const score = terms.reduce((total, term) => total + (catalog.includes(term) ? (/[._#$@*\d]/.test(term) ? 12 : 1) : 0), 0);
|
|
165
|
+
return score > 0 ? [{ seed, score }] : [];
|
|
166
|
+
}).sort((a, b) => b.score - a.score).slice(0, Math.min(limit, 5)).map(item => item.seed);
|
|
167
|
+
const refreshed = await Promise.allSettled(refreshCandidates.map(seed => this.fetchDocument(seed.url)));
|
|
168
|
+
refreshed.forEach((outcome, index) => {
|
|
169
|
+
if (outcome.status === "rejected")
|
|
170
|
+
refreshWarnings.push(`Could not refresh ${refreshCandidates[index].title}: ${outcome.reason?.message || String(outcome.reason)}`);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const matches = this.searchableEntries().flatMap(seed => {
|
|
174
|
+
if (releaseSet.size && !releaseSet.has(normalizeRelease(seed.release)))
|
|
175
|
+
return [];
|
|
176
|
+
if (categorySet.size && !seed.categories.some(category => categorySet.has(category.toLowerCase())))
|
|
177
|
+
return [];
|
|
178
|
+
const cached = this.cache.get(seed.url);
|
|
179
|
+
const title = cached?.title || seed.title;
|
|
180
|
+
const haystack = `${title} ${seed.keywords.join(" ")} ${seed.categories.join(" ")} ${cached?.content || ""}`.toLowerCase();
|
|
181
|
+
const titleText = title.toLowerCase();
|
|
182
|
+
const identifierTerms = terms.filter(term => /[._#$@*\d]/.test(term));
|
|
183
|
+
const score = terms.reduce((total, term) => {
|
|
184
|
+
if (titleText.includes(term))
|
|
185
|
+
return total + 8;
|
|
186
|
+
if (seed.keywords.some(keyword => keyword.toLowerCase().includes(term)))
|
|
187
|
+
return total + 4;
|
|
188
|
+
if (haystack.includes(term))
|
|
189
|
+
return total + 1;
|
|
190
|
+
return total;
|
|
191
|
+
}, 0) + identifierTerms.reduce((total, term) => total + (haystack.includes(term) ? 12 : 0), 0);
|
|
192
|
+
if (score === 0)
|
|
193
|
+
return [];
|
|
194
|
+
return [{ seed, cached, title, score }];
|
|
195
|
+
}).sort((a, b) => b.score - a.score || b.seed.release.localeCompare(a.seed.release) || a.title.localeCompare(b.title));
|
|
196
|
+
const selected = matches.slice(offset, offset + limit);
|
|
197
|
+
const hasMore = offset + selected.length < matches.length;
|
|
198
|
+
return {
|
|
199
|
+
query,
|
|
200
|
+
results: selected.map(({ seed, cached, title, score }) => {
|
|
201
|
+
const match = cached ? locateBestMatch(cached.content, terms) : undefined;
|
|
202
|
+
return ({
|
|
203
|
+
ref: encodeRef(seed.url),
|
|
204
|
+
title,
|
|
205
|
+
product: "IBM i",
|
|
206
|
+
release: seed.release,
|
|
207
|
+
topicId: new URL(seed.url).searchParams.get("topic") || undefined,
|
|
208
|
+
categories: seed.categories,
|
|
209
|
+
canonicalUrl: seed.url,
|
|
210
|
+
headingPath: match?.headingPath || [],
|
|
211
|
+
excerpt: cached ? excerptFor(cached.content, terms, match?.index) : seed.keywords.join(", "),
|
|
212
|
+
score,
|
|
213
|
+
matchReasons: cached
|
|
214
|
+
? ["official-doc-content", seed.opened ? "previously-opened-page" : "curated-official-catalog"]
|
|
215
|
+
: ["curated-official-catalog"],
|
|
216
|
+
freshness: cached ? (this.isExpired(cached) ? "stale" : "cached") : "catalog",
|
|
217
|
+
version: cached?.version,
|
|
218
|
+
observedAt: cached?.fetchedAt
|
|
219
|
+
});
|
|
220
|
+
}),
|
|
221
|
+
returned: selected.length,
|
|
222
|
+
hasMore,
|
|
223
|
+
...(hasMore ? { nextCursor: encodeCursor(offset + selected.length) } : {}),
|
|
224
|
+
coverage: {
|
|
225
|
+
sufficient: selected.length > 0 && selected.some(item => item.cached),
|
|
226
|
+
searchedSeeds: this.seeds.length,
|
|
227
|
+
cachedDocuments: matches.filter(item => item.cached).length
|
|
228
|
+
},
|
|
229
|
+
warnings: [
|
|
230
|
+
...refreshWarnings,
|
|
231
|
+
...(selected.some(item => item.cached) ? [] : ["Search used the curated official catalog; open a result to cache and search its full content."])
|
|
232
|
+
],
|
|
233
|
+
...(selected.some(item => item.cached) ? {} : {
|
|
234
|
+
webFallback: {
|
|
235
|
+
recommended: true,
|
|
236
|
+
query: `${query} IBM i ${options.releases?.[0] || ""}`.trim(),
|
|
237
|
+
preferredDomains: ["www.ibm.com/docs", "www.ibm.com/support", "www.redbooks.ibm.com", "developer.ibm.com"],
|
|
238
|
+
reason: selected.length ? "Catalog matches exist but their content has not been cached yet; open a result or use web search." : "No configured official documentation seed matched the query."
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
async open(options) {
|
|
244
|
+
// Any https://www.ibm.com/docs/ page may be opened; the seeds only drive search.
|
|
245
|
+
const url = options.url ? validateOfficialUrl(options.url).toString() : decodeRef(String(options.ref || ""));
|
|
246
|
+
const requireLive = options.requireLive || options.freshness === "require-live";
|
|
247
|
+
const cached = requireLive ? undefined : await this.cachedDocument(url);
|
|
248
|
+
const warnings = [];
|
|
249
|
+
let document;
|
|
250
|
+
let cacheStatus;
|
|
251
|
+
if (cached && !this.isExpired(cached)) {
|
|
252
|
+
document = cached;
|
|
253
|
+
cacheStatus = "hit";
|
|
254
|
+
}
|
|
255
|
+
else if (cached) {
|
|
256
|
+
try {
|
|
257
|
+
document = await this.fetchDocument(url);
|
|
258
|
+
cacheStatus = "refreshed";
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
// Serve the expired copy rather than nothing, and say so.
|
|
262
|
+
document = cached;
|
|
263
|
+
cacheStatus = "stale";
|
|
264
|
+
warnings.push(`Cached copy is older than the cache expiry and could not be refreshed: ${error?.message || String(error)}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
document = await this.fetchDocument(url);
|
|
269
|
+
cacheStatus = "miss";
|
|
270
|
+
}
|
|
271
|
+
if (options.expectedVersion && options.expectedVersion !== document.version) {
|
|
272
|
+
return {
|
|
273
|
+
status: "version-conflict",
|
|
274
|
+
expectedVersion: options.expectedVersion,
|
|
275
|
+
actualVersion: document.version,
|
|
276
|
+
canonicalUrl: url,
|
|
277
|
+
observedAt: document.fetchedAt
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const selected = selectDocumentContent(document.content, options);
|
|
281
|
+
const maximum = Math.max(1_000, Math.min(200_000, Math.floor(options.maxCharacters || 50_000)));
|
|
282
|
+
const complete = selected.content.length <= maximum;
|
|
283
|
+
return {
|
|
284
|
+
status: "found",
|
|
285
|
+
ref: encodeRef(url),
|
|
286
|
+
title: document.title,
|
|
287
|
+
product: "IBM i",
|
|
288
|
+
release: document.release,
|
|
289
|
+
topicId: new URL(document.url).searchParams.get("topic") || undefined,
|
|
290
|
+
canonicalUrl: document.url,
|
|
291
|
+
headingPath: selected.headingPath,
|
|
292
|
+
startLine: selected.startLine,
|
|
293
|
+
endLine: selected.endLine,
|
|
294
|
+
content: selected.content.slice(0, maximum),
|
|
295
|
+
version: document.version,
|
|
296
|
+
checksum: document.version,
|
|
297
|
+
observedAt: document.fetchedAt,
|
|
298
|
+
cacheStatus,
|
|
299
|
+
complete,
|
|
300
|
+
...(complete ? {} : { truncatedReason: `Document exceeds maxCharacters=${maximum}` }),
|
|
301
|
+
...(document.release === "unknown" || SUPPORTED_DOC_RELEASES.includes(document.release) ? {} : {
|
|
302
|
+
releaseNote: `IBM i ${document.release} is not a release this catalog covers (${SUPPORTED_DOC_RELEASES.join(", ")}).`
|
|
303
|
+
}),
|
|
304
|
+
...(warnings.length ? { warnings } : {})
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
listResources() {
|
|
308
|
+
return this.seeds.map(seed => ({
|
|
309
|
+
uri: `ibmi-docs://topic/${encodeURIComponent(encodeRef(seed.url))}`,
|
|
310
|
+
name: seed.title,
|
|
311
|
+
description: `Official IBM i ${seed.release} documentation`,
|
|
312
|
+
mimeType: "text/markdown"
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
async readResource(uri) {
|
|
316
|
+
if (uri === "ibmi-docs://catalog") {
|
|
317
|
+
return JSON.stringify(this.seeds, null, 2);
|
|
318
|
+
}
|
|
319
|
+
const prefix = "ibmi-docs://topic/";
|
|
320
|
+
if (!uri.startsWith(prefix))
|
|
321
|
+
throw new Error("Unknown IBM documentation resource URI");
|
|
322
|
+
const ref = decodeURIComponent(uri.slice(prefix.length));
|
|
323
|
+
const opened = await this.open({ ref });
|
|
324
|
+
if (opened.status !== "found")
|
|
325
|
+
throw new Error("IBM documentation resource changed; read it again");
|
|
326
|
+
return opened.content;
|
|
327
|
+
}
|
|
328
|
+
async fetchDocument(url) {
|
|
329
|
+
const existing = this.inflight.get(url);
|
|
330
|
+
if (existing)
|
|
331
|
+
return existing;
|
|
332
|
+
const loading = this.fetchDocumentUncoalesced(url);
|
|
333
|
+
this.inflight.set(url, loading);
|
|
334
|
+
try {
|
|
335
|
+
return await loading;
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
this.inflight.delete(url);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async fetchDocumentUncoalesced(url) {
|
|
342
|
+
let current = validateOfficialUrl(url);
|
|
343
|
+
const controller = new AbortController();
|
|
344
|
+
const timer = setTimeout(() => controller.abort(), 15_000);
|
|
345
|
+
timer.unref?.();
|
|
346
|
+
try {
|
|
347
|
+
let response;
|
|
348
|
+
for (let redirects = 0;; redirects += 1) {
|
|
349
|
+
response = await this.httpFetch(current, { redirect: "manual", signal: controller.signal, headers: { accept: "text/html" } });
|
|
350
|
+
if (![301, 302, 303, 307, 308].includes(response.status))
|
|
351
|
+
break;
|
|
352
|
+
await response.body?.cancel().catch(() => undefined);
|
|
353
|
+
const location = response.headers.get("location");
|
|
354
|
+
if (!location)
|
|
355
|
+
throw new Error("IBM documentation redirect had no location");
|
|
356
|
+
if (redirects >= MAX_REDIRECTS)
|
|
357
|
+
throw new Error("IBM documentation redirect limit exceeded");
|
|
358
|
+
// Redirects may only stay on https://www.ibm.com/docs/.
|
|
359
|
+
current = validateOfficialUrl(new URL(location, current).toString());
|
|
360
|
+
}
|
|
361
|
+
if (!response?.ok)
|
|
362
|
+
throw new Error(`IBM documentation request failed (${response?.status || "unknown"})`);
|
|
363
|
+
const contentType = response.headers.get("content-type") || "";
|
|
364
|
+
if (!contentType.toLowerCase().includes("text/html"))
|
|
365
|
+
throw new Error("IBM documentation response was not HTML");
|
|
366
|
+
const declaredLength = Number(response.headers.get("content-length") || 0);
|
|
367
|
+
if (declaredLength > MAX_BODY_BYTES)
|
|
368
|
+
throw new Error("IBM documentation response exceeds 12 MiB");
|
|
369
|
+
const html = await readBodyWithLimit(response, MAX_BODY_BYTES);
|
|
370
|
+
const seed = this.seeds.find(item => item.url === url);
|
|
371
|
+
const parsed = htmlToMarkdown(html);
|
|
372
|
+
const document = {
|
|
373
|
+
title: parsed.title || seed?.title || url,
|
|
374
|
+
url,
|
|
375
|
+
release: seed?.release || releaseFromUrl(url),
|
|
376
|
+
content: parsed.content,
|
|
377
|
+
version: crypto.createHash("sha256").update(parsed.content, "utf8").digest("hex"),
|
|
378
|
+
fetchedAt: new Date(this.now()).toISOString()
|
|
379
|
+
};
|
|
380
|
+
await this.ensureCacheDir();
|
|
381
|
+
await fs.writeFile(this.cachePath(url), JSON.stringify(document), "utf8");
|
|
382
|
+
this.cache.set(url, document);
|
|
383
|
+
return document;
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
clearTimeout(timer);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
isExpired(document) {
|
|
390
|
+
const fetched = Date.parse(document.fetchedAt);
|
|
391
|
+
if (!Number.isFinite(fetched))
|
|
392
|
+
return true;
|
|
393
|
+
return this.now() - fetched > this.cacheTtlMs;
|
|
394
|
+
}
|
|
395
|
+
/** Seeds plus any non-seed pages that were opened before, so they are searchable too. */
|
|
396
|
+
searchableEntries() {
|
|
397
|
+
const seedUrls = new Set(this.seeds.map(seed => seed.url));
|
|
398
|
+
const opened = [...this.cache.values()]
|
|
399
|
+
.filter(document => !seedUrls.has(document.url))
|
|
400
|
+
.map(document => ({
|
|
401
|
+
title: document.title,
|
|
402
|
+
url: document.url,
|
|
403
|
+
release: document.release,
|
|
404
|
+
categories: [],
|
|
405
|
+
keywords: [],
|
|
406
|
+
opened: true
|
|
407
|
+
}));
|
|
408
|
+
return [...this.seeds, ...opened];
|
|
409
|
+
}
|
|
410
|
+
ensureCacheDir() {
|
|
411
|
+
this.cacheDirReady ||= fs.mkdir(this.cacheDir, { recursive: true }).then(() => undefined, error => {
|
|
412
|
+
this.cacheDirReady = undefined;
|
|
413
|
+
throw error;
|
|
414
|
+
});
|
|
415
|
+
return this.cacheDirReady;
|
|
416
|
+
}
|
|
417
|
+
async cachedDocument(url) {
|
|
418
|
+
await this.loadCachedDocuments();
|
|
419
|
+
return this.cache.get(url);
|
|
420
|
+
}
|
|
421
|
+
async loadCachedDocuments() {
|
|
422
|
+
if (this.cacheLoaded)
|
|
423
|
+
return;
|
|
424
|
+
let entries = [];
|
|
425
|
+
try {
|
|
426
|
+
entries = await fs.readdir(this.cacheDir);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// No cache directory yet; it is created on the first fetch.
|
|
430
|
+
}
|
|
431
|
+
await Promise.all(entries.filter(name => /^[0-9a-f]{64}\.json$/.test(name)).map(async (name) => {
|
|
432
|
+
try {
|
|
433
|
+
const parsed = JSON.parse(await fs.readFile(path.join(this.cacheDir, name), "utf8"));
|
|
434
|
+
if (typeof parsed?.url !== "string" || typeof parsed.content !== "string")
|
|
435
|
+
return;
|
|
436
|
+
const url = validateOfficialUrl(parsed.url).toString();
|
|
437
|
+
if (this.cachePath(url) !== path.join(this.cacheDir, name))
|
|
438
|
+
return;
|
|
439
|
+
if (!this.cache.has(url))
|
|
440
|
+
this.cache.set(url, { ...parsed, url });
|
|
441
|
+
}
|
|
442
|
+
catch { }
|
|
443
|
+
}));
|
|
444
|
+
this.cacheLoaded = true;
|
|
445
|
+
}
|
|
446
|
+
cachePath(url) {
|
|
447
|
+
return path.join(this.cacheDir, `${crypto.createHash("sha256").update(url).digest("hex")}.json`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
export const ibmDocs = new IbmDocsService();
|
|
451
|
+
export function validateOfficialUrl(value) {
|
|
452
|
+
const raw = String(value ?? "").trim();
|
|
453
|
+
if (!raw || raw.length > 2048 || /[\s\\]/.test(raw))
|
|
454
|
+
throw new Error("IBM documentation URLs must use https://www.ibm.com/docs/");
|
|
455
|
+
let url;
|
|
456
|
+
try {
|
|
457
|
+
url = new URL(raw);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
throw new Error("IBM documentation URLs must use https://www.ibm.com/docs/");
|
|
461
|
+
}
|
|
462
|
+
if (url.username || url.password || /^https:\/\/[^/]*@/i.test(raw)) {
|
|
463
|
+
throw new Error("IBM documentation URLs may not include user information");
|
|
464
|
+
}
|
|
465
|
+
// An explicit port (even :443) is rejected by looking at the raw authority.
|
|
466
|
+
const authority = /^https:\/\/([^/?#]*)/i.exec(raw)?.[1] || "";
|
|
467
|
+
if (url.protocol !== "https:" || url.hostname !== "www.ibm.com" || url.port || authority.toLowerCase() !== "www.ibm.com" || !url.pathname.startsWith("/docs/")) {
|
|
468
|
+
throw new Error("IBM documentation URLs must use https://www.ibm.com/docs/");
|
|
469
|
+
}
|
|
470
|
+
url.hash = "";
|
|
471
|
+
return url;
|
|
472
|
+
}
|
|
473
|
+
/** IBM Docs paths look like /docs/en/i/7.5.0 or /docs/en/i/7.5; returns "7.5" or "unknown". */
|
|
474
|
+
export function releaseFromUrl(value) {
|
|
475
|
+
const match = /^\/docs\/[a-z]{2}(?:-[a-z]{2})?\/i\/(\d+)\.(\d+)(?:\.\d+)?(?:\/|$)/i.exec(new URL(value).pathname);
|
|
476
|
+
return match ? `${Number(match[1])}.${Number(match[2])}` : "unknown";
|
|
477
|
+
}
|
|
478
|
+
function resolveCacheTtl(option) {
|
|
479
|
+
if (typeof option === "number" && Number.isFinite(option) && option >= 0)
|
|
480
|
+
return option;
|
|
481
|
+
const days = Number(process.env.MCP_FOR_I_IBM_DOCS_CACHE_TTL_DAYS);
|
|
482
|
+
if (process.env.MCP_FOR_I_IBM_DOCS_CACHE_TTL_DAYS && Number.isFinite(days) && days >= 0)
|
|
483
|
+
return days * 24 * 60 * 60 * 1000;
|
|
484
|
+
return DEFAULT_CACHE_TTL_MS;
|
|
485
|
+
}
|
|
486
|
+
/** Reads the body as text, failing as soon as more than `limit` bytes arrive. */
|
|
487
|
+
async function readBodyWithLimit(response, limit) {
|
|
488
|
+
if (!response.body) {
|
|
489
|
+
const text = await response.text();
|
|
490
|
+
if (Buffer.byteLength(text, "utf8") > limit)
|
|
491
|
+
throw new Error("IBM documentation response exceeds 12 MiB");
|
|
492
|
+
return text;
|
|
493
|
+
}
|
|
494
|
+
const reader = response.body.getReader();
|
|
495
|
+
const chunks = [];
|
|
496
|
+
let total = 0;
|
|
497
|
+
while (true) {
|
|
498
|
+
const { done, value } = await reader.read();
|
|
499
|
+
if (done)
|
|
500
|
+
break;
|
|
501
|
+
total += value.byteLength;
|
|
502
|
+
if (total > limit) {
|
|
503
|
+
await reader.cancel().catch(() => undefined);
|
|
504
|
+
throw new Error("IBM documentation response exceeds 12 MiB");
|
|
505
|
+
}
|
|
506
|
+
chunks.push(Buffer.from(value));
|
|
507
|
+
}
|
|
508
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
509
|
+
}
|
|
510
|
+
function loadExtraSeeds() {
|
|
511
|
+
const raw = process.env.MCP_FOR_I_IBM_DOCS_SEEDS;
|
|
512
|
+
if (!raw)
|
|
513
|
+
return [];
|
|
514
|
+
try {
|
|
515
|
+
const parsed = JSON.parse(raw);
|
|
516
|
+
if (!Array.isArray(parsed))
|
|
517
|
+
return [];
|
|
518
|
+
return parsed.flatMap(item => {
|
|
519
|
+
if (!item || typeof item !== "object" || typeof item.title !== "string" || typeof item.url !== "string" || typeof item.release !== "string")
|
|
520
|
+
return [];
|
|
521
|
+
return [{
|
|
522
|
+
title: item.title,
|
|
523
|
+
url: item.url,
|
|
524
|
+
release: normalizeRelease(item.release),
|
|
525
|
+
categories: Array.isArray(item.categories) ? item.categories.map(String) : [],
|
|
526
|
+
keywords: Array.isArray(item.keywords) ? item.keywords.map(String) : []
|
|
527
|
+
}];
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function htmlToMarkdown(html) {
|
|
535
|
+
const title = decodeEntities((/<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] || "").replace(/<[^>]+>/g, " ")).trim();
|
|
536
|
+
const tables = [];
|
|
537
|
+
const withoutScripts = html
|
|
538
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
539
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "");
|
|
540
|
+
// Tables become Markdown tables. They are swapped for placeholders so the
|
|
541
|
+
// generic tag stripping below does not flatten them; innermost tables first.
|
|
542
|
+
let tableSource = withoutScripts;
|
|
543
|
+
for (let pass = 0; pass < 5 && /<table\b/i.test(tableSource); pass += 1) {
|
|
544
|
+
tableSource = tableSource.replace(/<table\b[^>]*>((?:(?!<table\b)[\s\S])*?)<\/table>/gi, (_all, body) => {
|
|
545
|
+
tables.push(htmlTableToMarkdown(body, tables));
|
|
546
|
+
return `\n\u0000TABLE${tables.length - 1}\u0000\n`;
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
const content = decodeEntities(tableSource
|
|
550
|
+
.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_all, level, text) => `\n${"#".repeat(Number(level))} ${stripTags(text)}\n`)
|
|
551
|
+
.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (_all, text) => `\n\`\`\`\n${stripTags(text).trim()}\n\`\`\`\n`)
|
|
552
|
+
.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, (_all, text) => `\`${stripTags(text).trim()}\``)
|
|
553
|
+
.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_all, text) => `\n- ${stripTags(text).trim()}`)
|
|
554
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
555
|
+
.replace(/<\/(p|div|section|article|tr)>/gi, "\n")
|
|
556
|
+
.replace(/<[^>]+>/g, " "))
|
|
557
|
+
.replace(/[ \t]+/g, " ")
|
|
558
|
+
.replace(/\n[ \t]+/g, "\n")
|
|
559
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
560
|
+
.replace(/\u0000TABLE(\d+)\u0000/g, (_all, index) => tables[Number(index)] || "")
|
|
561
|
+
.trim();
|
|
562
|
+
return { title, content };
|
|
563
|
+
}
|
|
564
|
+
/** Converts the inside of a <table> element into a Markdown table. */
|
|
565
|
+
export function htmlTableToMarkdown(body, nested = []) {
|
|
566
|
+
const caption = /<caption\b[^>]*>([\s\S]*?)<\/caption>/i.exec(body)?.[1];
|
|
567
|
+
const rows = [];
|
|
568
|
+
for (const rowMatch of body.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
|
|
569
|
+
const cells = [];
|
|
570
|
+
for (const cellMatch of rowMatch[1].matchAll(/<(th|td)\b([^>]*)>([\s\S]*?)<\/(?:th|td)>/gi)) {
|
|
571
|
+
const text = stripTags(cellMatch[3].replace(/<br\s*\/?>/gi, " "))
|
|
572
|
+
.replace(/\u0000TABLE(\d+)\u0000/g, (_all, index) => (nested[Number(index)] || "").replace(/\s*\n\s*/g, " "))
|
|
573
|
+
.replace(/\|/g, "\\|")
|
|
574
|
+
.trim();
|
|
575
|
+
cells.push(text);
|
|
576
|
+
const span = Number(/colspan\s*=\s*["']?(\d+)/i.exec(cellMatch[2])?.[1] || 1);
|
|
577
|
+
for (let extra = 1; extra < Math.min(span, 20); extra += 1)
|
|
578
|
+
cells.push("");
|
|
579
|
+
}
|
|
580
|
+
if (cells.length)
|
|
581
|
+
rows.push(cells);
|
|
582
|
+
}
|
|
583
|
+
const captionText = caption ? stripTags(caption).trim() : "";
|
|
584
|
+
if (!rows.length)
|
|
585
|
+
return captionText;
|
|
586
|
+
const width = Math.max(...rows.map(row => row.length));
|
|
587
|
+
const pad = (cells) => `| ${[...cells, ...Array(width - cells.length).fill("")].join(" | ")} |`;
|
|
588
|
+
// The first row is used as the header (IBM Docs tables start with <th> cells).
|
|
589
|
+
const [first, ...rest] = rows;
|
|
590
|
+
const lines = [pad(first), `| ${Array(width).fill("---").join(" | ")} |`, ...rest.map(pad)];
|
|
591
|
+
return [captionText ? `**${captionText}**` : "", ...lines].filter(Boolean).join("\n");
|
|
592
|
+
}
|
|
593
|
+
function stripTags(value) {
|
|
594
|
+
return decodeEntities(value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " "));
|
|
595
|
+
}
|
|
596
|
+
function decodeEntities(value) {
|
|
597
|
+
return value
|
|
598
|
+
.replace(/ /gi, " ")
|
|
599
|
+
.replace(/&/gi, "&")
|
|
600
|
+
.replace(/</gi, "<")
|
|
601
|
+
.replace(/>/gi, ">")
|
|
602
|
+
.replace(/"/gi, "\"")
|
|
603
|
+
.replace(/'/gi, "'");
|
|
604
|
+
}
|
|
605
|
+
function excerptFor(content, terms, preferredIndex) {
|
|
606
|
+
const lower = content.toLowerCase();
|
|
607
|
+
const indexes = terms.map(term => lower.indexOf(term)).filter(index => index >= 0);
|
|
608
|
+
const index = typeof preferredIndex === "number" ? preferredIndex : indexes.length ? Math.min(...indexes) : 0;
|
|
609
|
+
const start = Math.max(0, index - 180);
|
|
610
|
+
const end = Math.min(content.length, index + 420);
|
|
611
|
+
return `${start > 0 ? "…" : ""}${content.slice(start, end).replace(/\s+/g, " ")}${end < content.length ? "…" : ""}`;
|
|
612
|
+
}
|
|
613
|
+
function markdownSections(content) {
|
|
614
|
+
const lines = content.split("\n");
|
|
615
|
+
const headings = [];
|
|
616
|
+
const stack = [];
|
|
617
|
+
lines.forEach((line, index) => {
|
|
618
|
+
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
619
|
+
if (!match)
|
|
620
|
+
return;
|
|
621
|
+
const level = match[1].length;
|
|
622
|
+
stack.length = level - 1;
|
|
623
|
+
stack[level - 1] = match[2];
|
|
624
|
+
headings.push({ level, title: match[2], line: index + 1, path: stack.filter(Boolean) });
|
|
625
|
+
});
|
|
626
|
+
return headings.map((heading, index) => ({
|
|
627
|
+
headingPath: heading.path,
|
|
628
|
+
startLine: heading.line,
|
|
629
|
+
endLine: (headings[index + 1]?.line || lines.length + 1) - 1
|
|
630
|
+
}));
|
|
631
|
+
}
|
|
632
|
+
function locateBestMatch(content, terms) {
|
|
633
|
+
const lower = content.toLowerCase();
|
|
634
|
+
const indexes = terms.map(term => lower.indexOf(term)).filter(index => index >= 0);
|
|
635
|
+
const index = indexes.length ? Math.min(...indexes) : 0;
|
|
636
|
+
const line = content.slice(0, index).split("\n").length;
|
|
637
|
+
const section = markdownSections(content).filter(item => item.startLine <= line).at(-1);
|
|
638
|
+
return { index, headingPath: section?.headingPath || [] };
|
|
639
|
+
}
|
|
640
|
+
function selectDocumentContent(content, options) {
|
|
641
|
+
const lines = content.split("\n");
|
|
642
|
+
let baseStart = 1;
|
|
643
|
+
let baseEnd = lines.length;
|
|
644
|
+
let headingPath = [];
|
|
645
|
+
if (options.section) {
|
|
646
|
+
const query = options.section.trim().toLowerCase();
|
|
647
|
+
const section = markdownSections(content).find(item => item.headingPath.join(" > ").toLowerCase().includes(query));
|
|
648
|
+
if (!section)
|
|
649
|
+
throw new Error(`IBM documentation section not found: ${options.section}`);
|
|
650
|
+
baseStart = section.startLine;
|
|
651
|
+
baseEnd = section.endLine;
|
|
652
|
+
headingPath = section.headingPath;
|
|
653
|
+
}
|
|
654
|
+
const requestedStart = options.startLine === undefined ? baseStart : Math.floor(Number(options.startLine));
|
|
655
|
+
const requestedEnd = options.endLine === undefined ? baseEnd : Math.floor(Number(options.endLine));
|
|
656
|
+
if (!Number.isFinite(requestedStart) || !Number.isFinite(requestedEnd) || requestedStart < 1 || requestedEnd < requestedStart) {
|
|
657
|
+
throw new Error("Invalid IBM documentation line range");
|
|
658
|
+
}
|
|
659
|
+
const startLine = Math.max(baseStart, requestedStart);
|
|
660
|
+
const endLine = Math.min(baseEnd, requestedEnd);
|
|
661
|
+
if (endLine < startLine)
|
|
662
|
+
throw new Error("IBM documentation line range does not overlap the selected section");
|
|
663
|
+
return { headingPath, startLine, endLine, content: lines.slice(startLine - 1, endLine).join("\n") };
|
|
664
|
+
}
|
|
665
|
+
function encodeRef(url) {
|
|
666
|
+
return `ibmi-docs:${Buffer.from(url, "utf8").toString("base64url")}`;
|
|
667
|
+
}
|
|
668
|
+
function decodeRef(ref) {
|
|
669
|
+
if (!ref.startsWith("ibmi-docs:"))
|
|
670
|
+
throw new Error("Invalid IBM documentation ref");
|
|
671
|
+
return validateOfficialUrl(Buffer.from(ref.slice("ibmi-docs:".length), "base64url").toString("utf8")).toString();
|
|
672
|
+
}
|
|
673
|
+
function encodeCursor(offset) {
|
|
674
|
+
return `ibmi-docs:v1:${offset}`;
|
|
675
|
+
}
|
|
676
|
+
function decodeCursor(cursor) {
|
|
677
|
+
const match = /^ibmi-docs:v1:(\d+)$/.exec(cursor);
|
|
678
|
+
const offset = Number(match?.[1]);
|
|
679
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
680
|
+
throw new Error("Invalid IBM documentation cursor");
|
|
681
|
+
return offset;
|
|
682
|
+
}
|
|
683
|
+
function normalizeRelease(value) {
|
|
684
|
+
return String(value).trim().replace(/\.0$/, "");
|
|
685
|
+
}
|