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.
@@ -1,15 +1,50 @@
1
- import { clampNumber, enforceWritable, getConnectionName, json } from "../tools.js";
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
- const LOCAL_KEYS = new Set(["TAB", "BACKTAB", "HOME", "RESET", "ERASEEOF", "FIELDEXIT"]);
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) and at most one AID key at the end (Enter, F1-F24, PageUp, PageDown, Clear, Help, Print, Attn, SysReq). " +
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 session = { connectionName, host: stored.host, port, tls: useTls, client, createdAt: new Date().toISOString() };
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 enforceWritable(ctx, conn, "cl.run", { ...gateArgs, command, environment: "ile" });
381
+ await gate(ctx, conn, "cl.run", { ...gateArgs, command, environment: "ile" });
289
382
  }
290
383
  else {
291
- await enforceWritable(ctx, conn, "tn5250.sendKeys", gateArgs);
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
- settled = await client.waitForSettled(timeoutMs);
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 json({
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
- function parseKeys(value) {
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
- return String(value)
345
- .split(/[\s,+]+/)
346
- .map(part => part.trim().toUpperCase().replace(/[_-]/g, ""))
347
- .filter(Boolean);
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
+ }