mcp-for-i 0.2.0 → 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 +10 -0
- package/dist/controlplane/commands.js +38 -2
- package/dist/controlplane/server.js +58 -2
- package/dist/controlplane/serviceManager.js +35 -1
- package/dist/controlplane/ui.js +42 -1
- package/dist/ibmi/tn5250/client.js +28 -4
- package/dist/ibmi/tn5250/ebcdic.js +25 -6
- package/dist/ibmi/tn5250/ebcdicTables.js +27 -0
- package/dist/ibmi/tn5250/screen.js +206 -0
- package/dist/index.js +36 -2
- package/dist/mcp/modules/tn5250.js +302 -17
- 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/tools.js +20 -1
- package/docs/capabilities.md +264 -263
- package/docs/configuration.md +4 -0
- package/docs/tools.md +7 -3
- package/package.json +56 -56
|
@@ -1,15 +1,50 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import { clampNumber, enforceWritable, enforceWritableForPerson, getConnectionName, json } from "../tools.js";
|
|
2
3
|
import { getPassword } from "../../security/credentialStore.js";
|
|
3
4
|
import { REDACTED } from "../../security/redact.js";
|
|
4
|
-
import { Tn5250Client, aidForKey, checkTlsCertificate } from "../../ibmi/tn5250/index.js";
|
|
5
|
+
import { Tn5250Client, aidForKey, checkTlsCertificate, hostKeyFor } from "../../ibmi/tn5250/index.js";
|
|
6
|
+
import { SUPPORTED_CCSIDS } from "../../ibmi/tn5250/ebcdic.js";
|
|
5
7
|
import { AID_WITHOUT_DATA } from "../../ibmi/tn5250/datastream.js";
|
|
8
|
+
import { startLiveServer } from "./tn5250Live.js";
|
|
6
9
|
const sessions = new Map();
|
|
10
|
+
/** "changed" (connectionName) whenever a session's screen or state may have changed. */
|
|
11
|
+
export const liveEvents = new EventEmitter();
|
|
12
|
+
liveEvents.setMaxListeners(100);
|
|
13
|
+
/** The agent waits this long after the person typed or clicked in the live view. */
|
|
14
|
+
export const PERSON_HOLD_MS = 4000;
|
|
15
|
+
let liveServer;
|
|
16
|
+
let liveServerStarting;
|
|
17
|
+
/** Context of the MCP process, used for input that comes from the live view. */
|
|
18
|
+
let liveCtx;
|
|
7
19
|
/** Replaceable dependencies (tests swap these). */
|
|
8
20
|
export const tn5250Deps = {
|
|
9
21
|
getPassword: (connectionName) => getPassword(connectionName),
|
|
10
22
|
checkCertificate: (info) => checkTlsCertificate({ host: info.host, port: info.port, certificate: info.certificate, authorized: info.authorized })
|
|
11
23
|
};
|
|
12
|
-
|
|
24
|
+
/** Keys handled on the local screen (nothing is sent to the IBM i), by every name they go by. */
|
|
25
|
+
const LOCAL_KEY_ALIASES = {
|
|
26
|
+
TAB: "TAB", FIELDADVANCE: "TAB",
|
|
27
|
+
BACKTAB: "BACKTAB", SHIFTTAB: "BACKTAB", FIELDBACKSPACE: "BACKTAB",
|
|
28
|
+
HOME: "HOME",
|
|
29
|
+
END: "END", ENDOFFIELD: "END",
|
|
30
|
+
RESET: "RESET",
|
|
31
|
+
ERASEEOF: "ERASEEOF", ERASEEOFIELD: "ERASEEOF", ERASEENDOFFIELD: "ERASEEOF",
|
|
32
|
+
ERASEINPUT: "ERASEINPUT",
|
|
33
|
+
FIELDEXIT: "FIELDEXIT",
|
|
34
|
+
FIELDPLUS: "FIELDPLUS",
|
|
35
|
+
FIELDMINUS: "FIELDMINUS",
|
|
36
|
+
DUP: "DUP", DUPLICATE: "DUP",
|
|
37
|
+
INSERT: "INSERT", INS: "INSERT",
|
|
38
|
+
DELETE: "DELETE", DEL: "DELETE",
|
|
39
|
+
UP: "UP", ARROWUP: "UP", CURSORUP: "UP",
|
|
40
|
+
DOWN: "DOWN", ARROWDOWN: "DOWN", CURSORDOWN: "DOWN",
|
|
41
|
+
LEFT: "LEFT", ARROWLEFT: "LEFT", CURSORLEFT: "LEFT", BACKSPACE: "LEFT",
|
|
42
|
+
RIGHT: "RIGHT", ARROWRIGHT: "RIGHT", CURSORRIGHT: "RIGHT",
|
|
43
|
+
NEWLINE: "NEWLINE"
|
|
44
|
+
};
|
|
45
|
+
const LOCAL_KEYS = new Set(Object.values(LOCAL_KEY_ALIASES));
|
|
46
|
+
/** How long Attn / SysReq wait for any answer before the keyboard is unlocked again. */
|
|
47
|
+
const HOST_KEY_ANSWER_MS = 2000;
|
|
13
48
|
const CONNECTION_NAME = { type: "string", description: "Saved IBM i connection name (defaults to the active connection)." };
|
|
14
49
|
const TIMEOUT_MS = { type: "number", description: "How long to wait for the host, in milliseconds." };
|
|
15
50
|
const FIELD_ID = {
|
|
@@ -31,6 +66,10 @@ const tools = [
|
|
|
31
66
|
signOn: { type: "boolean", default: false, description: "Sign on with the stored credentials." },
|
|
32
67
|
deviceName: { type: "string", description: "Virtual device name to request (1-10 characters)." },
|
|
33
68
|
screenSize: { type: "string", enum: ["24x80", "27x132"], description: "27x132 announces an IBM-3477-FC display." },
|
|
69
|
+
ccsid: {
|
|
70
|
+
type: "number",
|
|
71
|
+
description: `Single-byte EBCDIC code page for screen text (default: the tn5250Ccsid setting, else 37). Supported: ${SUPPORTED_CCSIDS.join(", ")}.`
|
|
72
|
+
},
|
|
34
73
|
timeoutMs: TIMEOUT_MS,
|
|
35
74
|
approve: { type: "boolean", description: "Set true to approve guarded-policy operations (signOn)." }
|
|
36
75
|
},
|
|
@@ -66,7 +105,9 @@ const tools = [
|
|
|
66
105
|
{
|
|
67
106
|
name: "ibmi.tn5250.sendKeys",
|
|
68
107
|
description: "Type text at the cursor and/or press keys, then wait for the host to answer. keys is a space-separated list: local keys " +
|
|
69
|
-
"(Tab, BackTab, Home, Reset, EraseEOF, FieldExit
|
|
108
|
+
"(Tab, BackTab, Home, End, Reset, EraseEOF, EraseInput, FieldExit, Field+, Field-, Dup, Insert, Delete, Up, Down, Left, Right, NewLine; " +
|
|
109
|
+
"add *N to repeat, for example Down*3) and at most one host key at the end (Enter, F1-F24, PageUp, PageDown, Clear, Help, Print, Attn, SysReq). " +
|
|
110
|
+
"As in IBM emulators, Esc is Attn (Attention, which opens the job's attention menu or program) and Shift+Esc is SysReq (System Request). " +
|
|
70
111
|
"Pressing an AID key sends the screen to the IBM i: read-only connections refuse it, and text on a recognised command line (===>) is checked against the command policy as cl.run.",
|
|
71
112
|
inputSchema: {
|
|
72
113
|
type: "object",
|
|
@@ -83,6 +124,27 @@ const tools = [
|
|
|
83
124
|
},
|
|
84
125
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
|
|
85
126
|
},
|
|
127
|
+
{
|
|
128
|
+
name: "ibmi.tn5250.click",
|
|
129
|
+
description: "Click on the 5250 screen the way an emulator mouse does: a click moves the cursor to row/col; a click on a function-key legend " +
|
|
130
|
+
"(for example \"F3=Exit\") presses that key, and on \"More...\" presses PageDown; doubleClick moves the cursor and presses Enter. " +
|
|
131
|
+
"Pressing a key goes through the same checks as sendKeys.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
connectionName: CONNECTION_NAME,
|
|
136
|
+
row: { type: "number", description: "1-based row." },
|
|
137
|
+
col: { type: "number", description: "1-based column." },
|
|
138
|
+
doubleClick: { type: "boolean", default: false, description: "Move the cursor and press Enter." },
|
|
139
|
+
hotspots: { type: "boolean", default: true, description: "Press the key named by a function-key legend under the click." },
|
|
140
|
+
timeoutMs: TIMEOUT_MS,
|
|
141
|
+
approve: { type: "boolean", description: "Set true to approve guarded-policy operations." }
|
|
142
|
+
},
|
|
143
|
+
required: ["row", "col"],
|
|
144
|
+
additionalProperties: false
|
|
145
|
+
},
|
|
146
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
|
|
147
|
+
},
|
|
86
148
|
{
|
|
87
149
|
name: "ibmi.tn5250.waitFor",
|
|
88
150
|
description: "Wait until text appears on the screen and/or the keyboard unlocks (the host is waiting for input). With no text it waits for the keyboard.",
|
|
@@ -112,6 +174,17 @@ const tools = [
|
|
|
112
174
|
}
|
|
113
175
|
];
|
|
114
176
|
async function handle(ctx, name, args) {
|
|
177
|
+
liveCtx = ctx;
|
|
178
|
+
try {
|
|
179
|
+
return await handleTool(ctx, name, args);
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
const connectionName = args?.connectionName || ctx.activeName;
|
|
183
|
+
if (connectionName && sessions.has(connectionName))
|
|
184
|
+
liveEvents.emit("changed", connectionName);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function handleTool(ctx, name, args) {
|
|
115
188
|
switch (name) {
|
|
116
189
|
case "ibmi.tn5250.connect":
|
|
117
190
|
return connect(ctx, args || {});
|
|
@@ -121,6 +194,8 @@ async function handle(ctx, name, args) {
|
|
|
121
194
|
return setField(ctx, args || {});
|
|
122
195
|
case "ibmi.tn5250.sendKeys":
|
|
123
196
|
return sendKeys(ctx, args || {});
|
|
197
|
+
case "ibmi.tn5250.click":
|
|
198
|
+
return click(ctx, args || {});
|
|
124
199
|
case "ibmi.tn5250.waitFor":
|
|
125
200
|
return waitFor(ctx, args || {});
|
|
126
201
|
case "ibmi.tn5250.snapshot": {
|
|
@@ -158,6 +233,7 @@ async function connect(ctx, args) {
|
|
|
158
233
|
const deviceName = optionalDeviceName(args.deviceName ?? settings.tn5250DeviceName);
|
|
159
234
|
const terminalType = args.screenSize === "27x132" ? "IBM-3477-FC" : "IBM-3179-2";
|
|
160
235
|
const timeoutMs = clampNumber(args.timeoutMs, 20000, 1000, 120000);
|
|
236
|
+
const ccsid = requireCcsid(args.ccsid ?? settings.tn5250Ccsid ?? 37);
|
|
161
237
|
let password;
|
|
162
238
|
if (args.signOn) {
|
|
163
239
|
const conn = await ctx.ensureActive(connectionName);
|
|
@@ -172,14 +248,20 @@ async function connect(ctx, args) {
|
|
|
172
248
|
tls: useTls,
|
|
173
249
|
terminalType,
|
|
174
250
|
deviceName,
|
|
251
|
+
ccsid,
|
|
175
252
|
// The password is only offered in the telnet negotiation when it is encrypted by TLS.
|
|
176
253
|
autoSignOn: password && useTls ? { user: stored.username, password } : undefined,
|
|
177
254
|
connectTimeoutMs: timeoutMs,
|
|
178
255
|
checkCertificate: info => tn5250Deps.checkCertificate(info)
|
|
179
256
|
});
|
|
180
257
|
await client.connect();
|
|
181
|
-
const
|
|
258
|
+
const attnWaitMs = settings.tn5250AttnWaitMs !== undefined ? clampNumber(settings.tn5250AttnWaitMs, HOST_KEY_ANSWER_MS, 250, 15000) : HOST_KEY_ANSWER_MS;
|
|
259
|
+
const session = { connectionName, host: stored.host, port, tls: useTls, client, createdAt: new Date().toISOString(), attnWaitMs };
|
|
182
260
|
sessions.set(connectionName, session);
|
|
261
|
+
client.on("update", () => liveEvents.emit("changed", connectionName));
|
|
262
|
+
client.on("close", () => liveEvents.emit("changed", connectionName));
|
|
263
|
+
if (settings.tn5250LiveView !== false)
|
|
264
|
+
await ensureLiveServer(settings.tn5250LiveViewPort);
|
|
183
265
|
try {
|
|
184
266
|
let settled = await client.waitForSettled(timeoutMs);
|
|
185
267
|
session.device = deviceFromScreen(client.screen) || deviceName;
|
|
@@ -219,6 +301,7 @@ function signOnFields(screen) {
|
|
|
219
301
|
}
|
|
220
302
|
async function setField(ctx, args) {
|
|
221
303
|
const session = requireOpenSession(ctx, args);
|
|
304
|
+
holdForPerson(session);
|
|
222
305
|
const screen = session.client.screen;
|
|
223
306
|
const field = resolveField(screen, args);
|
|
224
307
|
if (!field)
|
|
@@ -242,10 +325,16 @@ async function setField(ctx, args) {
|
|
|
242
325
|
if (screen.inputInhibited)
|
|
243
326
|
screen.reset();
|
|
244
327
|
screen.setFieldValue(field, value);
|
|
328
|
+
session.lastActor = "agent";
|
|
245
329
|
return json(sessionView(session));
|
|
246
330
|
}
|
|
247
331
|
async function sendKeys(ctx, args) {
|
|
248
332
|
const session = requireOpenSession(ctx, args);
|
|
333
|
+
holdForPerson(session);
|
|
334
|
+
return json(await runInput(ctx, session, args, "agent"));
|
|
335
|
+
}
|
|
336
|
+
/** Type, press local keys and at most one host key; shared by the tools (agent) and the live view (person). */
|
|
337
|
+
async function runInput(ctx, session, args, actor) {
|
|
249
338
|
const { client } = session;
|
|
250
339
|
const screen = client.screen;
|
|
251
340
|
const keys = parseKeys(args.keys);
|
|
@@ -254,8 +343,9 @@ async function sendKeys(ctx, args) {
|
|
|
254
343
|
throw new Error("keys or text is required");
|
|
255
344
|
const aidIndex = keys.findIndex(isAidKey);
|
|
256
345
|
for (const key of keys) {
|
|
257
|
-
if (!LOCAL_KEYS.has(key) && !isAidKey(key))
|
|
258
|
-
throw new Error(`Unsupported key: ${key}
|
|
346
|
+
if (!LOCAL_KEYS.has(key) && !isAidKey(key)) {
|
|
347
|
+
throw new Error(`Unsupported key: ${key}. Esc is Attn and Shift+Esc is SysReq; see the tool description for the key names.`);
|
|
348
|
+
}
|
|
259
349
|
}
|
|
260
350
|
if (aidIndex >= 0 && aidIndex !== keys.length - 1) {
|
|
261
351
|
throw new Error("Only one AID key (Enter, F1-F24, PageUp, ...) is allowed, and it must be the last key");
|
|
@@ -269,10 +359,12 @@ async function sendKeys(ctx, args) {
|
|
|
269
359
|
}
|
|
270
360
|
if (text)
|
|
271
361
|
screen.typeText(text);
|
|
362
|
+
session.lastActor = actor;
|
|
272
363
|
const localKeys = aidIndex >= 0 ? keys.slice(0, aidIndex) : keys;
|
|
273
364
|
for (const key of localKeys)
|
|
274
365
|
applyLocalKey(screen, key);
|
|
275
366
|
let settled;
|
|
367
|
+
let note;
|
|
276
368
|
const aidKey = aidIndex >= 0 ? keys[aidIndex] : undefined;
|
|
277
369
|
if (aidKey) {
|
|
278
370
|
if (screen.keyboardLocked && !screen.inputInhibited && !["ATTN", "SYSREQ"].includes(aidKey)) {
|
|
@@ -282,23 +374,140 @@ async function sendKeys(ctx, args) {
|
|
|
282
374
|
const gateArgs = { ...args, connectionName: session.connectionName };
|
|
283
375
|
const aid = aidForKey(aidKey);
|
|
284
376
|
const commands = aid !== undefined && !AID_WITHOUT_DATA.has(aid) ? commandLineEntries(screen) : [];
|
|
377
|
+
const gate = actor === "person" ? enforceWritableForPerson : enforceWritable;
|
|
285
378
|
if (commands.length > 0) {
|
|
286
379
|
// Text on a command line is run as CL by the host, so it gets the cl.run checks.
|
|
287
380
|
for (const command of commands)
|
|
288
|
-
await
|
|
381
|
+
await gate(ctx, conn, "cl.run", { ...gateArgs, command, environment: "ile" });
|
|
289
382
|
}
|
|
290
383
|
else {
|
|
291
|
-
await
|
|
384
|
+
await gate(ctx, conn, "tn5250.sendKeys", gateArgs);
|
|
292
385
|
}
|
|
386
|
+
const hostKey = aidKey === "ATTN" || aidKey === "SYSREQ";
|
|
387
|
+
const wasLocked = screen.keyboardLocked;
|
|
388
|
+
const recordsBefore = client.recordsReceived;
|
|
293
389
|
client.sendKey(aidKey);
|
|
294
390
|
session.lastKey = aidKey;
|
|
295
|
-
|
|
391
|
+
if (hostKey) {
|
|
392
|
+
// The host may not answer Attn (no attention program) or SysReq; don't leave the keyboard locked forever.
|
|
393
|
+
const startedAt = Date.now();
|
|
394
|
+
const waitMs = Math.min(timeoutMs, session.attnWaitMs ?? HOST_KEY_ANSWER_MS);
|
|
395
|
+
const answered = await client.waitFor(() => client.recordsReceived > recordsBefore, waitMs);
|
|
396
|
+
if (!answered) {
|
|
397
|
+
screen.keyboardLocked = wasLocked;
|
|
398
|
+
settled = !wasLocked;
|
|
399
|
+
note = aidKey === "ATTN"
|
|
400
|
+
? "This screen has no attention menu: no attention program is active here. Use F3 to exit or F12 to cancel to go back. The keyboard was unlocked again."
|
|
401
|
+
: "The host did not answer System Request on this screen. The keyboard was unlocked again.";
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
settled = await client.waitForSettled(Math.max(250, timeoutMs - (Date.now() - startedAt)));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
settled = await client.waitForSettled(timeoutMs);
|
|
409
|
+
}
|
|
296
410
|
}
|
|
297
|
-
return
|
|
411
|
+
return {
|
|
298
412
|
...sessionView(session),
|
|
299
413
|
...(aidKey ? { sent: aidKey, settled } : {}),
|
|
414
|
+
...(note ? { note } : {}),
|
|
300
415
|
...(autoReset ? { autoReset: true } : {})
|
|
301
|
-
}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
async function click(ctx, args) {
|
|
419
|
+
const session = requireOpenSession(ctx, args);
|
|
420
|
+
holdForPerson(session);
|
|
421
|
+
return json(await runClick(ctx, session, args, "agent"));
|
|
422
|
+
}
|
|
423
|
+
async function runClick(ctx, session, args, actor) {
|
|
424
|
+
const screen = session.client.screen;
|
|
425
|
+
const row = Number(args.row);
|
|
426
|
+
const col = Number(args.col);
|
|
427
|
+
screen.address(row, col); // validates the position
|
|
428
|
+
const key = args.doubleClick ? "ENTER" : args.hotspots === false ? undefined : screen.hotspotAt(row, col);
|
|
429
|
+
if (key) {
|
|
430
|
+
return runInput(ctx, session, { connectionName: session.connectionName, cursorRow: row, cursorCol: col, keys: key, timeoutMs: args.timeoutMs, approve: args.approve }, actor);
|
|
431
|
+
}
|
|
432
|
+
if (screen.inputInhibited)
|
|
433
|
+
screen.reset();
|
|
434
|
+
screen.setCursor(row, col);
|
|
435
|
+
screen.version += 1;
|
|
436
|
+
session.lastActor = actor;
|
|
437
|
+
return sessionView(session);
|
|
438
|
+
}
|
|
439
|
+
/** The agent must not type over the person: refuse while they are using the live view. */
|
|
440
|
+
function holdForPerson(session) {
|
|
441
|
+
if (session.personInputAt && Date.now() - session.personInputAt < PERSON_HOLD_MS) {
|
|
442
|
+
throw new Error("The person is typing in the live 5250 view. Wait a few seconds, read the screen again (ibmi.tn5250.readScreen) and then continue.");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async function ensureLiveServer(port) {
|
|
446
|
+
if (liveServer)
|
|
447
|
+
return liveServer;
|
|
448
|
+
if (!liveServerStarting) {
|
|
449
|
+
const preferred = Number.isInteger(Number(port)) && Number(port) >= 0 && Number(port) <= 65535 && port !== undefined ? Number(port) : 3981;
|
|
450
|
+
liveServerStarting = startLiveServer(liveProvider, preferred)
|
|
451
|
+
.then(server => { liveServer = server; return server; })
|
|
452
|
+
.catch(() => undefined)
|
|
453
|
+
.finally(() => { liveServerStarting = undefined; });
|
|
454
|
+
}
|
|
455
|
+
return liveServerStarting;
|
|
456
|
+
}
|
|
457
|
+
/** What the live view server reads and drives. */
|
|
458
|
+
const liveProvider = {
|
|
459
|
+
events: liveEvents,
|
|
460
|
+
list: () => [...sessions.values()].map(session => ({
|
|
461
|
+
connectionName: session.connectionName,
|
|
462
|
+
host: session.host,
|
|
463
|
+
connected: !session.client.closed,
|
|
464
|
+
signedOn: Boolean(session.signedOn),
|
|
465
|
+
lastActor: session.lastActor
|
|
466
|
+
})),
|
|
467
|
+
screen: (connectionName) => {
|
|
468
|
+
const session = sessions.get(connectionName);
|
|
469
|
+
if (!session)
|
|
470
|
+
return undefined;
|
|
471
|
+
const screen = session.client.screen;
|
|
472
|
+
return { ...sessionView(session), runs: screen.renderRuns(), personHoldMs: PERSON_HOLD_MS };
|
|
473
|
+
},
|
|
474
|
+
input: async (connectionName, input) => {
|
|
475
|
+
const session = sessions.get(connectionName);
|
|
476
|
+
if (!session)
|
|
477
|
+
throw new Error(`No 5250 session for ${connectionName}`);
|
|
478
|
+
if (session.client.closed)
|
|
479
|
+
throw new Error(`The 5250 session for ${connectionName} is closed`);
|
|
480
|
+
if (!liveCtx)
|
|
481
|
+
throw new Error("The MCP server is not ready");
|
|
482
|
+
session.personInputAt = Date.now();
|
|
483
|
+
try {
|
|
484
|
+
if (input.click) {
|
|
485
|
+
return await runClick(liveCtx, session, { ...input.click, timeoutMs: 30000 }, "person");
|
|
486
|
+
}
|
|
487
|
+
return await runInput(liveCtx, session, {
|
|
488
|
+
connectionName,
|
|
489
|
+
text: input.text,
|
|
490
|
+
keys: input.keys,
|
|
491
|
+
cursorRow: input.cursorRow,
|
|
492
|
+
cursorCol: input.cursorCol,
|
|
493
|
+
timeoutMs: 30000
|
|
494
|
+
}, "person");
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
session.personInputAt = Date.now();
|
|
498
|
+
liveEvents.emit("changed", connectionName);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
/** Address of the live view for a session, once the live view server runs. */
|
|
503
|
+
export function liveViewUrl(connectionName) {
|
|
504
|
+
return liveServer?.urlFor(connectionName);
|
|
505
|
+
}
|
|
506
|
+
/** For tests: stop the live view server. */
|
|
507
|
+
export async function stopLiveServer() {
|
|
508
|
+
const server = liveServer;
|
|
509
|
+
liveServer = undefined;
|
|
510
|
+
await server?.close();
|
|
302
511
|
}
|
|
303
512
|
async function waitFor(ctx, args) {
|
|
304
513
|
const session = requireSession(ctx, args);
|
|
@@ -324,27 +533,92 @@ function applyLocalKey(screen, key) {
|
|
|
324
533
|
case "HOME":
|
|
325
534
|
screen.home();
|
|
326
535
|
break;
|
|
536
|
+
case "END":
|
|
537
|
+
screen.endOfField();
|
|
538
|
+
break;
|
|
327
539
|
case "RESET":
|
|
328
540
|
screen.reset();
|
|
329
541
|
break;
|
|
330
542
|
case "ERASEEOF":
|
|
331
543
|
screen.eraseToEndOfField();
|
|
332
544
|
break;
|
|
545
|
+
case "ERASEINPUT":
|
|
546
|
+
screen.eraseInput();
|
|
547
|
+
break;
|
|
333
548
|
case "FIELDEXIT":
|
|
334
549
|
screen.fieldExit();
|
|
335
550
|
break;
|
|
551
|
+
case "FIELDPLUS":
|
|
552
|
+
screen.fieldSign(false);
|
|
553
|
+
break;
|
|
554
|
+
case "FIELDMINUS":
|
|
555
|
+
screen.fieldSign(true);
|
|
556
|
+
break;
|
|
557
|
+
case "DUP":
|
|
558
|
+
screen.dup();
|
|
559
|
+
break;
|
|
560
|
+
case "INSERT":
|
|
561
|
+
screen.toggleInsert();
|
|
562
|
+
break;
|
|
563
|
+
case "DELETE":
|
|
564
|
+
screen.deleteChar();
|
|
565
|
+
break;
|
|
566
|
+
case "UP":
|
|
567
|
+
screen.moveCursor(-1, 0);
|
|
568
|
+
break;
|
|
569
|
+
case "DOWN":
|
|
570
|
+
screen.moveCursor(1, 0);
|
|
571
|
+
break;
|
|
572
|
+
case "LEFT":
|
|
573
|
+
screen.moveCursor(0, -1);
|
|
574
|
+
break;
|
|
575
|
+
case "RIGHT":
|
|
576
|
+
screen.moveCursor(0, 1);
|
|
577
|
+
break;
|
|
578
|
+
case "NEWLINE":
|
|
579
|
+
screen.newLine();
|
|
580
|
+
break;
|
|
336
581
|
}
|
|
337
582
|
}
|
|
338
583
|
function isAidKey(key) {
|
|
339
584
|
return key === "ATTN" || key === "SYSREQ" || aidForKey(key) !== undefined;
|
|
340
585
|
}
|
|
341
|
-
|
|
586
|
+
/**
|
|
587
|
+
* Key names to canonical keys. Accepts "Field+", "Field-", "Shift+Tab",
|
|
588
|
+
* "Shift+Esc", "Tab+Enter" style chords and "Down*3" repeats.
|
|
589
|
+
*/
|
|
590
|
+
export function parseKeys(value) {
|
|
342
591
|
if (value === undefined || value === null)
|
|
343
592
|
return [];
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
593
|
+
const text = String(value).replace(/\bshift\s*\+\s*/gi, "SHIFT+");
|
|
594
|
+
const out = [];
|
|
595
|
+
for (const raw of text.split(/[\s,]+/).filter(Boolean)) {
|
|
596
|
+
const repeat = /^(.+?)\*(\d{1,3})$/.exec(raw);
|
|
597
|
+
const token = repeat ? repeat[1] : raw;
|
|
598
|
+
const count = repeat ? Number(repeat[2]) : 1;
|
|
599
|
+
const keys = canonicalKeys(token);
|
|
600
|
+
if (repeat && (keys.length !== 1 || !LOCAL_KEYS.has(keys[0])))
|
|
601
|
+
throw new Error(`Only a single local key can be repeated: ${raw}`);
|
|
602
|
+
if (count < 1 || count > 200)
|
|
603
|
+
throw new Error(`Repeat count must be 1-200: ${raw}`);
|
|
604
|
+
for (let n = 0; n < count; n += 1)
|
|
605
|
+
out.push(...keys);
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
function canonicalKeys(token) {
|
|
610
|
+
const upper = token.toUpperCase().replace(/_/g, "");
|
|
611
|
+
if (upper === "FIELD+")
|
|
612
|
+
return ["FIELDPLUS"];
|
|
613
|
+
if (upper === "FIELD-")
|
|
614
|
+
return ["FIELDMINUS"];
|
|
615
|
+
if (upper.startsWith("SHIFT+"))
|
|
616
|
+
return [canonicalKey(`SHIFT${upper.substring(6)}`)];
|
|
617
|
+
return upper.split("+").filter(Boolean).map(canonicalKey);
|
|
618
|
+
}
|
|
619
|
+
function canonicalKey(name) {
|
|
620
|
+
const key = name.replace(/-/g, "");
|
|
621
|
+
return LOCAL_KEY_ALIASES[key] || hostKeyFor(key) || key;
|
|
348
622
|
}
|
|
349
623
|
/**
|
|
350
624
|
* Text in command lines (fields labelled ===>). Unmodified ones are included
|
|
@@ -390,6 +664,9 @@ function sessionView(session) {
|
|
|
390
664
|
connected: !client.closed,
|
|
391
665
|
...(client.closed ? { closeReason: client.closeReason } : {}),
|
|
392
666
|
lastKey: session.lastKey,
|
|
667
|
+
...(session.lastActor ? { lastActor: session.lastActor } : {}),
|
|
668
|
+
...(session.personInputAt && Date.now() - session.personInputAt < PERSON_HOLD_MS ? { personTyping: true } : {}),
|
|
669
|
+
...(liveServer ? { liveViewUrl: liveServer.urlFor(session.connectionName) } : {}),
|
|
393
670
|
...client.screen.view(),
|
|
394
671
|
...(client.screen.warnings.length > 0 ? { warnings: client.screen.warnings.slice(-5) } : {})
|
|
395
672
|
};
|
|
@@ -449,8 +726,16 @@ function closeSession(connectionName) {
|
|
|
449
726
|
return false;
|
|
450
727
|
session.client.close();
|
|
451
728
|
sessions.delete(connectionName);
|
|
729
|
+
liveEvents.emit("changed", connectionName);
|
|
452
730
|
return true;
|
|
453
731
|
}
|
|
732
|
+
function requireCcsid(value) {
|
|
733
|
+
const ccsid = Number(value);
|
|
734
|
+
if (!SUPPORTED_CCSIDS.includes(ccsid)) {
|
|
735
|
+
throw new Error(`Unsupported TN5250 CCSID: ${value}. Supported single-byte code pages: ${SUPPORTED_CCSIDS.join(", ")}`);
|
|
736
|
+
}
|
|
737
|
+
return ccsid;
|
|
738
|
+
}
|
|
454
739
|
function requirePort(value) {
|
|
455
740
|
const port = Number(value);
|
|
456
741
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Apps view for the 5250 tools: clients that support MCP Apps draw the
|
|
3
|
+
* screen inside the chat. The view talks to the host over postMessage
|
|
4
|
+
* (JSON-RPC, ui/* methods); it refreshes with ibmi.tn5250.readScreen and sends
|
|
5
|
+
* input with ibmi.tn5250.sendKeys / ibmi.tn5250.click, so the usual checks and
|
|
6
|
+
* confirmations apply. "Open live view" opens the localhost page.
|
|
7
|
+
*
|
|
8
|
+
* The client script must not contain backticks or "${" (it sits in a template literal).
|
|
9
|
+
*/
|
|
10
|
+
export const TN5250_APP_URI = "ui://mcp-for-i/tn5250-screen.html";
|
|
11
|
+
export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
|
|
12
|
+
/** Tools whose results the view can draw. */
|
|
13
|
+
export const TN5250_APP_TOOLS = new Set([
|
|
14
|
+
"ibmi.tn5250.connect",
|
|
15
|
+
"ibmi.tn5250.readScreen",
|
|
16
|
+
"ibmi.tn5250.sendKeys",
|
|
17
|
+
"ibmi.tn5250.click",
|
|
18
|
+
"ibmi.tn5250.setField",
|
|
19
|
+
"ibmi.tn5250.waitFor",
|
|
20
|
+
"ibmi.tn5250.snapshot"
|
|
21
|
+
]);
|
|
22
|
+
export function renderTn5250App(version) {
|
|
23
|
+
return `<!doctype html>
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="utf-8" />
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
28
|
+
<title>5250 screen</title>
|
|
29
|
+
<style>
|
|
30
|
+
:root { color-scheme: light dark; --fg: #33ff66; --bg: #050805; --muted: #8aa094; --line: #24312a; --accent: #33ff66; --err: #ff6b6b; }
|
|
31
|
+
* { box-sizing: border-box; }
|
|
32
|
+
body { margin: 0; font: 12px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--muted); background: transparent; }
|
|
33
|
+
.bar { display: flex; gap: 8px; align-items: center; padding: 6px 2px; flex-wrap: wrap; }
|
|
34
|
+
.bar b { color: inherit; font-weight: 600; }
|
|
35
|
+
button { font: inherit; border: 1px solid var(--line); border-radius: 6px; padding: 2px 8px; background: transparent; color: inherit; cursor: pointer; }
|
|
36
|
+
#term { background: var(--bg); color: var(--fg); border-radius: 8px; padding: 8px 10px; outline: none; overflow-x: auto; border: 1px solid var(--line); }
|
|
37
|
+
#term:focus { border-color: var(--accent); }
|
|
38
|
+
#screen { position: relative; font: 13px/1.25 "Cascadia Mono", Consolas, Menlo, monospace; white-space: pre; user-select: none; }
|
|
39
|
+
.row { height: 1.25em; }
|
|
40
|
+
.fld { text-decoration: underline; }
|
|
41
|
+
#cursor { position: absolute; width: 1ch; height: 1.25em; background: var(--accent); opacity: .5; pointer-events: none; }
|
|
42
|
+
#msg { min-height: 1.2em; color: var(--err); padding-top: 4px; }
|
|
43
|
+
</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<div class="bar"><b id="title">5250</b><span id="state"></span><span style="flex:1"></span><button id="live" hidden>Open live view</button><button id="refresh">Refresh</button></div>
|
|
47
|
+
<div id="term" tabindex="0"><div id="screen"><div id="cursor"></div></div></div>
|
|
48
|
+
<div id="msg"></div>
|
|
49
|
+
<script>
|
|
50
|
+
(function () {
|
|
51
|
+
var nextId = 1, pending = {}, view = null, connectionName = null, ready = false, typed = '', typeTimer = null, busy = false;
|
|
52
|
+
var screenEl = document.getElementById('screen'), cursorEl = document.getElementById('cursor'), term = document.getElementById('term');
|
|
53
|
+
var msg = document.getElementById('msg'), stateEl = document.getElementById('state'), titleEl = document.getElementById('title'), liveBtn = document.getElementById('live');
|
|
54
|
+
|
|
55
|
+
function post(obj) { window.parent.postMessage(obj, '*'); }
|
|
56
|
+
function request(method, params) {
|
|
57
|
+
var id = nextId++;
|
|
58
|
+
post({ jsonrpc: '2.0', id: id, method: method, params: params || {} });
|
|
59
|
+
return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; });
|
|
60
|
+
}
|
|
61
|
+
function notify(method, params) { post({ jsonrpc: '2.0', method: method, params: params || {} }); }
|
|
62
|
+
|
|
63
|
+
window.addEventListener('message', function (ev) {
|
|
64
|
+
var m = ev.data;
|
|
65
|
+
if (!m || m.jsonrpc !== '2.0') return;
|
|
66
|
+
if (m.id !== undefined && pending[m.id]) {
|
|
67
|
+
var p = pending[m.id]; delete pending[m.id];
|
|
68
|
+
if (m.error) p.reject(new Error(m.error.message || 'request failed')); else p.resolve(m.result);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (m.method === 'ui/notifications/tool-result') showResult(m.params);
|
|
72
|
+
if (m.method === 'ui/notifications/tool-input' && m.params && m.params.arguments && m.params.arguments.connectionName) connectionName = m.params.arguments.connectionName;
|
|
73
|
+
if (m.method === 'ui/notifications/host-context-changed' && m.params) applyTheme(m.params.theme);
|
|
74
|
+
if (m.method === 'ui/resource-teardown' && m.id !== undefined) post({ jsonrpc: '2.0', id: m.id, result: {} });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function applyTheme(theme) {
|
|
78
|
+
if (theme === 'light') { document.documentElement.style.setProperty('--muted', '#4b5a52'); document.documentElement.style.setProperty('--line', '#c9d4ce'); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function screenOf(result) {
|
|
82
|
+
var data = result && result.structuredContent && result.structuredContent.data;
|
|
83
|
+
if (!data) return null;
|
|
84
|
+
if (Array.isArray(data.text)) return data;
|
|
85
|
+
if (data.screen && Array.isArray(data.screen.text)) return data.screen;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function showResult(result) {
|
|
89
|
+
if (result && result.isError) { msg.textContent = textOf(result); return; }
|
|
90
|
+
var s = screenOf(result);
|
|
91
|
+
if (s) { msg.textContent = (result.structuredContent.data.note || ''); render(s); }
|
|
92
|
+
}
|
|
93
|
+
function textOf(result) { return (result && result.content && result.content[0] && result.content[0].text) || 'Error'; }
|
|
94
|
+
|
|
95
|
+
function render(s) {
|
|
96
|
+
view = s; connectionName = s.connectionName || connectionName;
|
|
97
|
+
titleEl.textContent = (s.connectionName || '5250') + ' ' + s.rows + 'x' + s.cols;
|
|
98
|
+
var st = [];
|
|
99
|
+
if (!s.connected && s.connected !== undefined) st.push('disconnected');
|
|
100
|
+
if (s.keyboardLocked && !s.inputInhibited) st.push('X System');
|
|
101
|
+
if (s.inputInhibited) st.push('input inhibited');
|
|
102
|
+
if (s.lastActor === 'person') st.push('you acted last');
|
|
103
|
+
stateEl.textContent = st.join(' · ');
|
|
104
|
+
liveBtn.hidden = !s.liveViewUrl;
|
|
105
|
+
var fieldsByRow = {};
|
|
106
|
+
(s.fields || []).forEach(function (f) { if (!f.protected) (fieldsByRow[f.row] = fieldsByRow[f.row] || []).push(f); });
|
|
107
|
+
var frag = document.createDocumentFragment();
|
|
108
|
+
s.text.forEach(function (line, i) {
|
|
109
|
+
var row = document.createElement('div'); row.className = 'row'; row.dataset.row = String(i + 1);
|
|
110
|
+
var fl = (fieldsByRow[i + 1] || []).sort(function (a, b) { return a.col - b.col; });
|
|
111
|
+
var pos = 0;
|
|
112
|
+
fl.forEach(function (f) {
|
|
113
|
+
var start = f.col - 1, end = Math.min(line.length, start + f.length);
|
|
114
|
+
if (start > pos) row.appendChild(document.createTextNode(line.substring(pos, start)));
|
|
115
|
+
var span = document.createElement('span'); span.className = 'fld'; span.textContent = line.substring(start, end); row.appendChild(span);
|
|
116
|
+
pos = end;
|
|
117
|
+
});
|
|
118
|
+
row.appendChild(document.createTextNode(line.substring(pos)));
|
|
119
|
+
frag.appendChild(row);
|
|
120
|
+
});
|
|
121
|
+
screenEl.querySelectorAll('.row').forEach(function (n) { n.remove(); });
|
|
122
|
+
screenEl.appendChild(frag);
|
|
123
|
+
cursorEl.style.left = (s.cursor.col - 1) + 'ch';
|
|
124
|
+
cursorEl.style.top = ((s.cursor.row - 1) * 1.25) + 'em';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function call(name, args) {
|
|
128
|
+
if (!connectionName) return Promise.resolve();
|
|
129
|
+
busy = true;
|
|
130
|
+
var a = Object.assign({ connectionName: connectionName }, args);
|
|
131
|
+
return request('tools/call', { name: name, arguments: a })
|
|
132
|
+
.then(function (r) { showResult(r); })
|
|
133
|
+
.catch(function (e) { msg.textContent = e.message; })
|
|
134
|
+
.then(function () { busy = false; });
|
|
135
|
+
}
|
|
136
|
+
function flush(keys) {
|
|
137
|
+
if (typeTimer) { clearTimeout(typeTimer); typeTimer = null; }
|
|
138
|
+
var args = {};
|
|
139
|
+
if (typed) { args.text = typed; typed = ''; }
|
|
140
|
+
if (keys) args.keys = keys;
|
|
141
|
+
if (args.text || args.keys) return call('ibmi.tn5250.sendKeys', args);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
term.addEventListener('keydown', function (ev) {
|
|
145
|
+
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
|
146
|
+
var k = ev.key, name = null;
|
|
147
|
+
if (/^F([1-9]|1[0-2])$/.test(k)) name = 'F' + (Number(k.substring(1)) + (ev.shiftKey ? 12 : 0));
|
|
148
|
+
else if (k === 'Enter') name = 'Enter';
|
|
149
|
+
else if (k === 'Escape') name = ev.shiftKey ? 'SysReq' : 'Attn';
|
|
150
|
+
else if (k === 'Tab') name = ev.shiftKey ? 'BackTab' : 'Tab';
|
|
151
|
+
else if (k === 'ArrowUp') name = 'Up'; else if (k === 'ArrowDown') name = 'Down';
|
|
152
|
+
else if (k === 'ArrowLeft') name = 'Left'; else if (k === 'ArrowRight') name = 'Right';
|
|
153
|
+
else if (k === 'Home') name = 'Home'; else if (k === 'End') name = 'End';
|
|
154
|
+
else if (k === 'Delete') name = 'Delete'; else if (k === 'Backspace') name = 'Left Delete';
|
|
155
|
+
else if (k === 'PageUp') name = 'PageUp'; else if (k === 'PageDown') name = 'PageDown';
|
|
156
|
+
if (name) { ev.preventDefault(); flush(name); return; }
|
|
157
|
+
if (k.length === 1) { ev.preventDefault(); typed += k; if (typeTimer) clearTimeout(typeTimer); typeTimer = setTimeout(function () { flush(); }, 300); }
|
|
158
|
+
});
|
|
159
|
+
screenEl.addEventListener('click', function (ev) {
|
|
160
|
+
term.focus();
|
|
161
|
+
var row = ev.target.closest ? ev.target.closest('.row') : null;
|
|
162
|
+
if (!row || !view) return;
|
|
163
|
+
var rect = screenEl.getBoundingClientRect();
|
|
164
|
+
var chWidth = rect.width / view.cols;
|
|
165
|
+
var col = Math.max(1, Math.min(view.cols, Math.floor((ev.clientX - rect.left) / chWidth) + 1));
|
|
166
|
+
flush();
|
|
167
|
+
call('ibmi.tn5250.click', { row: Number(row.dataset.row), col: col, doubleClick: ev.detail >= 2 });
|
|
168
|
+
});
|
|
169
|
+
document.getElementById('refresh').addEventListener('click', function () { call('ibmi.tn5250.readScreen', {}); });
|
|
170
|
+
liveBtn.addEventListener('click', function () { if (view && view.liveViewUrl) request('ui/open-link', { url: view.liveViewUrl }).catch(function (e) { msg.textContent = e.message; }); });
|
|
171
|
+
|
|
172
|
+
setInterval(function () {
|
|
173
|
+
if (ready && connectionName && !busy && !typed && document.visibilityState === 'visible') call('ibmi.tn5250.readScreen', {});
|
|
174
|
+
}, 2000);
|
|
175
|
+
|
|
176
|
+
if (window.ResizeObserver) new ResizeObserver(function () {
|
|
177
|
+
notify('ui/notifications/size-changed', { width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight });
|
|
178
|
+
}).observe(document.body);
|
|
179
|
+
|
|
180
|
+
request('ui/initialize', {
|
|
181
|
+
protocolVersion: '2026-01-26',
|
|
182
|
+
capabilities: {},
|
|
183
|
+
clientInfo: { name: 'mcp-for-i-5250', version: '${version}' },
|
|
184
|
+
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }
|
|
185
|
+
}).then(function (res) {
|
|
186
|
+
ready = true;
|
|
187
|
+
if (res && res.hostContext) applyTheme(res.hostContext.theme);
|
|
188
|
+
notify('ui/notifications/initialized', {});
|
|
189
|
+
}).catch(function (e) { msg.textContent = 'Could not start: ' + e.message; });
|
|
190
|
+
})();
|
|
191
|
+
</script>
|
|
192
|
+
</body>
|
|
193
|
+
</html>`;
|
|
194
|
+
}
|