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.
@@ -35,6 +35,8 @@ export class Tn5250Field {
35
35
  return value === 5 ? "zero" : value === 6 ? "blank" : undefined;
36
36
  }
37
37
  get end() { return this.start + this.length - 1; }
38
+ /** The Dup key is allowed in this field. */
39
+ get dupEnabled() { return (this.ffw & 0x1000) !== 0; }
38
40
  contains(address) {
39
41
  return address >= this.start && address <= this.end;
40
42
  }
@@ -63,6 +65,8 @@ export class Screen {
63
65
  keyboardLocked = true;
64
66
  inputInhibited = false;
65
67
  messageWaiting = false;
68
+ /** Insert mode: typing shifts the rest of the field right instead of overwriting. */
69
+ insertMode = false;
66
70
  pendingRead = null;
67
71
  errorRow = 23;
68
72
  version = 0;
@@ -641,6 +645,16 @@ export class Screen {
641
645
  if (offset + bytes.length > field.length) {
642
646
  throw new Error(`Text is ${bytes.length} characters but only ${field.length - offset} fit in the field from the cursor`);
643
647
  }
648
+ if (this.insertMode) {
649
+ const tail = this.fieldBytes(field).slice(offset);
650
+ let used = tail.length;
651
+ while (used > 0 && (tail[used - 1] === 0 || tail[used - 1] === 0x40))
652
+ used -= 1;
653
+ if (offset + bytes.length + used > field.length) {
654
+ throw new Error("Insert mode: the field has no room to shift its text right");
655
+ }
656
+ tail.slice(0, used).forEach((byte, n) => { this.buffer[(field.start + offset + bytes.length + n) % this.size] = byte; });
657
+ }
644
658
  bytes.forEach((byte, n) => {
645
659
  const p = (field.start + offset + n) % this.size;
646
660
  this.buffer[p] = byte;
@@ -711,10 +725,146 @@ export class Screen {
711
725
  field.modified = true;
712
726
  this.version += 1;
713
727
  }
728
+ /** Field Exit: erase to the end of the field, right-adjust it if the field asks for that, go to the next field. */
714
729
  fieldExit() {
730
+ const field = this.currentInputField();
731
+ this.eraseToEndOfField();
732
+ const adjust = field.rightAdjust;
733
+ if (adjust)
734
+ this.rightAdjustField(field, adjust === "zero" ? 0xf0 : 0x40);
735
+ this.tab();
736
+ }
737
+ /** Field+ and Field-: like Field Exit; numeric fields are right-adjusted and Field- marks them negative. */
738
+ fieldSign(negative) {
739
+ const field = this.currentInputField();
740
+ if (negative && !field.numeric)
741
+ throw new Error("Field- is only valid in a numeric field");
715
742
  this.eraseToEndOfField();
743
+ if (field.numeric || field.rightAdjust) {
744
+ const fill = field.rightAdjust === "zero" ? 0xf0 : 0x40;
745
+ if (field.shift === 7) {
746
+ // Signed numeric: the last position holds the sign.
747
+ this.rightAdjustField(field, fill, 1);
748
+ this.buffer[field.end % this.size] = negative ? 0x60 : 0x40;
749
+ }
750
+ else {
751
+ this.rightAdjustField(field, fill);
752
+ if (negative) {
753
+ const last = field.end % this.size;
754
+ const byte = this.buffer[last];
755
+ if (byte < 0xf0 || byte > 0xf9)
756
+ throw new Error("Field- needs a digit in the last position of the field");
757
+ this.buffer[last] = 0xd0 | (byte & 0x0f);
758
+ }
759
+ }
760
+ }
761
+ field.modified = true;
762
+ this.version += 1;
763
+ this.tab();
764
+ }
765
+ /** Dup: fill the field from the cursor with the DUP character (the host repeats the previous value). */
766
+ dup() {
767
+ this.requireInputAllowed();
768
+ const field = this.currentInputField();
769
+ if (!field.dupEnabled)
770
+ throw new Error("This field does not allow the Dup key");
771
+ for (let p = this.cursor; p <= field.end; p += 1) {
772
+ this.buffer[p % this.size] = 0x1c;
773
+ this.attrMap[p % this.size] = 0;
774
+ }
775
+ field.modified = true;
776
+ this.version += 1;
716
777
  this.tab();
717
778
  }
779
+ /** Delete the character at the cursor; the rest of the field shifts left. */
780
+ deleteChar() {
781
+ this.requireInputAllowed();
782
+ const field = this.currentInputField();
783
+ const offset = this.cursor - field.start;
784
+ const bytes = this.fieldBytes(field);
785
+ const shifted = [...bytes.slice(0, offset), ...bytes.slice(offset + 1), 0];
786
+ shifted.forEach((byte, n) => { this.buffer[(field.start + n) % this.size] = byte; });
787
+ field.modified = true;
788
+ this.version += 1;
789
+ }
790
+ /** Erase Input: clear every input field and put the cursor in the first one. */
791
+ eraseInput() {
792
+ this.requireInputAllowed();
793
+ for (const field of this.inputFields()) {
794
+ if (this.fieldHasValue(field))
795
+ field.modified = true;
796
+ this.nullField(field);
797
+ }
798
+ this.home();
799
+ this.version += 1;
800
+ }
801
+ toggleInsert() {
802
+ this.insertMode = !this.insertMode;
803
+ }
804
+ /** Move the cursor by rows and columns, wrapping around the screen like a terminal. */
805
+ moveCursor(rowDelta, colDelta) {
806
+ const size = this.size;
807
+ this.cursor = (((this.cursor + rowDelta * this.cols + colDelta) % size) + size) % size;
808
+ }
809
+ /** New Line: the first input field on a following row (or the first field on the screen). */
810
+ newLine() {
811
+ const inputs = this.inputFields();
812
+ const nextRow = (Math.floor(this.cursor / this.cols) + 1) * this.cols;
813
+ const next = inputs.find(f => f.start >= nextRow) || inputs[0];
814
+ this.cursor = next ? next.start : nextRow % this.size;
815
+ }
816
+ /** End: just after the last character entered in the current field. */
817
+ endOfField() {
818
+ const field = this.currentInputField();
819
+ const bytes = this.fieldBytes(field);
820
+ let used = bytes.length;
821
+ while (used > 0 && (bytes[used - 1] === 0 || bytes[used - 1] === 0x40))
822
+ used -= 1;
823
+ this.cursor = field.start + Math.min(used, field.length - 1);
824
+ }
825
+ currentInputField() {
826
+ const field = this.fieldAt(this.cursor);
827
+ if (!field || field.bypass) {
828
+ const { row, col } = this.rowCol(this.cursor);
829
+ throw new Error(`The cursor (row ${row}, column ${col}) is not in an input field`);
830
+ }
831
+ return field;
832
+ }
833
+ fieldBytes(field) {
834
+ const out = [];
835
+ for (let n = 0; n < field.length; n += 1)
836
+ out.push(this.buffer[(field.start + n) % this.size]);
837
+ return out;
838
+ }
839
+ /** Move the field's text to the right end (leaving `reserve` positions free) and fill the left with `fill`. */
840
+ rightAdjustField(field, fill, reserve = 0) {
841
+ const bytes = this.fieldBytes(field).filter(b => b !== 0 && b !== 0x40);
842
+ const width = field.length - reserve;
843
+ if (bytes.length > width)
844
+ throw new Error("The field's text does not fit when right-adjusted");
845
+ const adjusted = [...new Array(width - bytes.length).fill(fill), ...bytes];
846
+ adjusted.forEach((byte, n) => { this.buffer[(field.start + n) % this.size] = byte; });
847
+ field.modified = true;
848
+ }
849
+ /**
850
+ * What a mouse click on this position presses, as emulator "hotspots" do:
851
+ * a function-key legend such as "F3=Exit" gives that key, "More..." gives PageDown.
852
+ */
853
+ hotspotAt(row, col) {
854
+ const line = this.renderRows()[row - 1] || "";
855
+ const index = col - 1;
856
+ for (const match of line.matchAll(/\b(F(?:[1-9]|1[0-9]|2[0-4]))=\S+/g)) {
857
+ const start = match.index ?? 0;
858
+ if (index >= start && index < start + match[0].length)
859
+ return match[1];
860
+ }
861
+ for (const match of line.matchAll(/More\.\.\./g)) {
862
+ const start = match.index ?? 0;
863
+ if (index >= start && index < start + match[0].length)
864
+ return "PAGEDOWN";
865
+ }
866
+ return undefined;
867
+ }
718
868
  // ------------------------------------------------------------------- views
719
869
  /** Screen rows as text. Attributes, nulls and non-display areas are blanks. */
720
870
  renderRows() {
@@ -736,6 +886,9 @@ export class Screen {
736
886
  else if ((attr & 0x07) === 0x07 || hiddenMask[p]) {
737
887
  line += " ";
738
888
  }
889
+ else if (this.buffer[p] === 0x1c) {
890
+ line += "*";
891
+ }
739
892
  else {
740
893
  line += ebcdicToChar(this.buffer[p], this.ccsid);
741
894
  }
@@ -746,6 +899,47 @@ export class Screen {
746
899
  }
747
900
  return rows;
748
901
  }
902
+ /**
903
+ * Screen rows as runs of text with the same display attribute (color,
904
+ * reverse, underline), for drawing the screen. Hidden text is blank.
905
+ */
906
+ renderRuns() {
907
+ const rows = this.renderRows();
908
+ const out = [];
909
+ // Underline follows the field's defined length, not the attribute span: a
910
+ // real emulator underlines only the enterable positions, so a one-character
911
+ // field shows a one-character line, however far the host's underline
912
+ // attribute happens to run before the next attribute byte.
913
+ const underlineMask = new Uint8Array(this.size);
914
+ for (const field of this.fields) {
915
+ if (!attributeStyle(field.attr).underline)
916
+ continue;
917
+ for (let n = 0; n < field.length; n += 1)
918
+ underlineMask[(field.start + n) % this.size] = 1;
919
+ }
920
+ let attr = 0x20;
921
+ for (let r = 0; r < this.rows; r += 1) {
922
+ const runs = [];
923
+ for (let c = 0; c < this.cols; c += 1) {
924
+ const p = r * this.cols + c;
925
+ const isAttr = this.attrMap[p] !== 0;
926
+ if (isAttr)
927
+ attr = this.buffer[p];
928
+ const style = attributeStyle(attr);
929
+ const underline = underlineMask[p] === 1;
930
+ const ch = rows[r][c];
931
+ const last = runs[runs.length - 1];
932
+ if (last && last.color === style.color && last.reverse === style.reverse && last.underline === underline && !isAttr) {
933
+ last.text += ch;
934
+ }
935
+ else {
936
+ runs.push({ text: ch, color: style.color, reverse: style.reverse, underline });
937
+ }
938
+ }
939
+ out.push(runs);
940
+ }
941
+ return out;
942
+ }
749
943
  fieldId(field) {
750
944
  const { row, col } = this.rowCol(field.start);
751
945
  return `r${row}c${col}`;
@@ -808,6 +1002,7 @@ export class Screen {
808
1002
  inputInhibited: this.inputInhibited,
809
1003
  messageWaiting: this.messageWaiting,
810
1004
  readPending: this.pendingRead !== null,
1005
+ insertMode: this.insertMode,
811
1006
  text: rows,
812
1007
  fields,
813
1008
  version: this.version
@@ -822,6 +1017,17 @@ export class Screen {
822
1017
  this.warnings.shift();
823
1018
  }
824
1019
  }
1020
+ /** Display style of a 5250 screen attribute byte (0x20-0x3F). */
1021
+ export function attributeStyle(attr) {
1022
+ const group = attr & 0x38;
1023
+ const alt = (attr & 0x02) !== 0;
1024
+ const color = group === 0x20 ? (alt ? "white" : "green")
1025
+ : group === 0x28 ? "red"
1026
+ : group === 0x30 ? (alt ? "yellow" : "turquoise")
1027
+ : alt ? "blue" : "pink";
1028
+ const nonDisplay = (attr & 0x07) === 0x07;
1029
+ return { color, reverse: !nonDisplay && (attr & 0x01) !== 0, underline: !nonDisplay && (attr & 0x04) !== 0 };
1030
+ }
825
1031
  function hex(value) {
826
1032
  return (value ?? 0).toString(16).padStart(2, "0");
827
1033
  }
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { appendAuditRecord } from "./mcp/audit.js";
15
15
  import { redactModuleArgs } from "./mcp/modules/index.js";
16
16
  import { runControlCommand } from "./controlplane/commands.js";
17
17
  import { ibmDocs } from "./ibmi/IbmDocs.js";
18
+ import { MCP_APP_MIME_TYPE, TN5250_APP_TOOLS, TN5250_APP_URI, renderTn5250App } from "./mcp/modules/tn5250App.js";
18
19
  const argv = process.argv.slice(2);
19
20
  const firstArg = (argv[0] || "").toLowerCase();
20
21
  const packageVersion = readPackageVersion();
@@ -36,7 +37,10 @@ if (firstArg === "--help" || firstArg === "-h" || firstArg === "help") {
36
37
  const server = new Server({ name: "mcp-for-i", version: packageVersion }, { capabilities: { tools: {}, resources: {} } });
37
38
  const ctx = new McpContext();
38
39
  ctx.confirm = createElicitationConfirm(server);
39
- const tools = getTools();
40
+ // 5250 tools point MCP Apps clients at the screen view; other clients ignore _meta.
41
+ const tools = getTools().map(tool => (TN5250_APP_TOOLS.has(tool.name)
42
+ ? { ...tool, _meta: { ...(tool._meta || {}), ui: { resourceUri: TN5250_APP_URI } } }
43
+ : tool));
40
44
  const toolMap = new Map(tools.map(tool => [tool.name, tool]));
41
45
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
42
46
  tools
@@ -49,6 +53,12 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
49
53
  description: "Release-aware allowlist of official IBM i documentation available through mcp-for-i.",
50
54
  mimeType: "application/json"
51
55
  },
56
+ {
57
+ uri: TN5250_APP_URI,
58
+ name: "5250 screen view",
59
+ description: "Interactive 5250 screen for MCP Apps clients (used by the ibmi.tn5250 tools).",
60
+ mimeType: MCP_APP_MIME_TYPE
61
+ },
52
62
  ...ibmDocs.listResources()
53
63
  ]
54
64
  }));
@@ -62,6 +72,11 @@ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
62
72
  }));
63
73
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
64
74
  const uri = request.params.uri;
75
+ if (uri === TN5250_APP_URI) {
76
+ return {
77
+ contents: [{ uri, mimeType: MCP_APP_MIME_TYPE, text: renderTn5250App(packageVersion), _meta: { ui: { prefersBorder: false } } }]
78
+ };
79
+ }
65
80
  const text = await ibmDocs.readResource(uri);
66
81
  return {
67
82
  contents: [{
@@ -132,7 +147,24 @@ function summarizeResult(result) {
132
147
  return {
133
148
  isError: Boolean(result?.isError),
134
149
  contentCount,
135
- hasStructuredContent: typeof result?.structuredContent !== "undefined"
150
+ hasStructuredContent: typeof result?.structuredContent !== "undefined",
151
+ ...summarizeScreen(result?.structuredContent)
152
+ };
153
+ }
154
+ /** For 5250 results: the screen's first text line and state, so the audit shows where a session went. */
155
+ function summarizeScreen(structured) {
156
+ const content = structured?.data ?? structured;
157
+ const screen = content && Array.isArray(content.text) ? content : content?.screen;
158
+ if (!screen || !Array.isArray(screen.text) || typeof screen.version !== "number")
159
+ return {};
160
+ const title = String(screen.text.find((line) => typeof line === "string" && line.trim()) || "").trim().substring(0, 80);
161
+ return {
162
+ screen: {
163
+ title: redactSecretsInText(title),
164
+ keyboardLocked: Boolean(screen.keyboardLocked),
165
+ ...(screen.errorLine ? { errorLine: redactSecretsInText(String(screen.errorLine).substring(0, 80)) } : {}),
166
+ ...(typeof content.sent === "string" ? { sent: content.sent } : {})
167
+ }
136
168
  };
137
169
  }
138
170
  function resolveConnectionName(tool, args, activeName) {
@@ -157,6 +189,8 @@ function printHelp() {
157
189
  process.stderr.write(` mcp-for-i Start MCP stdio server\n`);
158
190
  process.stderr.write(` mcp-for-i control Start/open control plane UI\n`);
159
191
  process.stderr.write(` mcp-for-i control status Show control plane and background-service status\n`);
192
+ process.stderr.write(` mcp-for-i control stop Stop the running control plane\n`);
193
+ process.stderr.write(` mcp-for-i control restart Stop the control plane and start it again\n`);
160
194
  process.stderr.write(` mcp-for-i control enable Enable background service for this platform\n`);
161
195
  process.stderr.write(` mcp-for-i control disable Disable background service for this platform\n`);
162
196
  process.stderr.write(` mcp-for-i --version Print package version\n`);