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
|
@@ -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
|
-
|
|
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`);
|