SteelFrame X · MCP — the IBM i engine as a reference oracle

Two MCP doors onto this engine (the stdio bridge bin/steelframex-mcp.mjs and POST /mcp), one route registry, two lanes. The prose below is MCP-IBMI.md; the tool reference at the bottom is fetched LIVE from GET /api/mcp/tools — exactly what an MCP client receives from tools/list. ← terminal · ops console

What it is

SteelFrame X is an IBM i (AS/400)-style engine: CL, RPG IV / RPG III, COBOL, DDS physical/logical/display/printer files, DB2 for i SQL, job queues and subsystems, spooled files, job schedule entries, 5250 display sessions. Through MCP an external agent or CI harness uses it as a reference oracle for modernization comparison: the engine returns what the machine holds and does — source members, records beside their bytes, a batch job's joblog and spool, 5250 screens one AID key at a time, scheduler runs, state dumps — and never judges equivalence and never converts code. The harness replays the same inputs against the modernized system and compares on its side.

Every tool is ONE REST route with an mcp flag: the name, description, input schema, annotations, result guards and the lane projection are generated from the route (ibmi/mcptools.js), so the catalog cannot drift from the dispatcher and there is no hand-written tool glue in either door.

Which lane? Persistent vs private / ephemeral

Persistent default

Everything lands in the shared estate and is visible in the web 5250 as your user profile (WRKACTJOB shows your QMCPSRV job). Isolation is opt-in: create_workspace, then workspace:<id> on any tool call runs it in a child engine on a copy of the estate (seed: clone | fresh, optional fixed clock, optional job-number counter) — the live estate never changes. Artifacts (run records, state dumps, bundles, save files) persist and are fetched later.

Private / ephemeral lane=ephemeral

RAM-only, zero retention. One one-shot (build_and_run: upload + compile + CALL in a single-use sandbox destroyed before the response returns) and RAM-only workspaces: create_workspace FIRST, then workspace:<id> on every other tool (refused in-band otherwise — nothing ever runs on the live estate from this lane); destroy_workspace or the TTL SIGKILLs the child and removes its directory. Fetch artifacts before destroying. The RAM property is a hosted-Linux fact (/dev/shm); every workspace view carries an honest ramBacked flag (false on macOS dev and on Windows). The catalog is the persistent one plus build_and_run, with workspace required on every routed tool — that is why the two counts on the buttons below differ.

Authentication and authority

API keyPOST /api/mcpkeys {label, ceiling} → sfx_<id>_<secret> (hashed in <IBMI_DATA>/mcpkeys.json). ceiling clamps the key: *READ (GET-class tools), *RUN (run/write/submit as yourself), *ALL (default). Minting for another profile needs *SECADM. Set SF_TOKEN.
User + passwordSF_USER + SF_PASSWORD: the bridge performs POST /api/logon (the same password seam as the 5250 sign-on — LDAP-aware, lockout accounting, *DISABLED / expired-password refusals with their real message ids) and re-logons on any 401.
AuthorityThe engine's own, unchanged: every call runs AS the real profile in a real job — object authority (SFF9802), special authorities (*JOBCTL, *SPLCTL, *SECADM, *SAVSYS), the CL commands' own rules. A profile set *DISABLED stops authorizing at the next request. Nothing an MCP caller can do exceeds what the profile could do on a 5250. The aut badge on each tool below is the route's coarse class.
AuditEvery mutating route emits an MCP audit event (syslog / stdout mirror, metadata only — never bodies). The private lane's workspace proxy records {method, bytes} only.

Client config

{ "mcpServers": {
    "steelframex": { "command": "node", "args": ["/path/to/AS400/bin/steelframex-mcp.mjs"],
      "env": { "SF_URL": "http://localhost:5250", "SF_USER": "QPGMR", "SF_PASSWORD": "QPGMR" } },
    "steelframex-private": { "command": "node", "args": ["/path/to/AS400/bin/steelframex-mcp.mjs"],
      "env": { "SF_URL": "http://localhost:5250", "SF_TOKEN": "sfx_…", "SF_LANE": "ephemeral" } } } }

Remote clients: POST /mcp (?lane=ephemeral) with Authorization: Bearer <session token | sfx_ key>. The bridge refuses plain http:// to non-localhost engines unless SF_ALLOW_HTTP=1.

The oracle surface, by capability both lanes

CapabilityToolsWhat comes back
Find / describesearch_source, find_program, list_libraries/objects/members, read_source, describe_file, describe_program, screen_schema, ddl_generate, analyzethe member's REAL source type (no sniffing); a program's files with DDS layouts and usage, its display files with their screen schema, callers, who submits it (CL members with CALL/SBMJOB + job schedule entries)
Records outdecode_records, encode_records, read_table, export_state, export_bundle (+ get_bundle_part, tar)rows (JSON) and rowsText (exact scaled-decimal strings from the packed/zoned nibbles) beside BOTH byte truths, named: hex.store (the program-charset image the compiled guest reads) and hex.ebcdic (the CCSID-37 image DSPPFM *HEX shows); sha256 = transfer integrity only
Seed inupload_source, write_source, compile, load_file, save_library / savf_download / savf_upload / restore_librarythe real CRTxxx commands with their QPRINT listing; rows through the DDS layout (triggers/journaling fire); this engine's ZSAVF1 media AND real IBM i save-file media (a customer seeds the oracle from their own SAVLIB)
Batch → joblogrun_batch (cmd → SBMJOB, stream → a //BCHJOB stream via SBMDBJOB — the JCL-deck analog, member), submit_job, wait_job, job_log, spool_read, run_command (every CL verb), call_program (parms written back — the COMMAREA analog), sql, run_sql_streamthe whole joblog with real message ids, every spooled file, output files (explicit or auto from the program's file meta) decoded beside their bytes, before-images, SQL tables; a persisted run record steelframex-run/1
Online, screen by screenscreen_open {command} → screen_send {aid, fields BY NAME, rrn} → screen_read → screen_close; screen_run_scripta real interactive job; the step settles STRUCTURALLY — at the next screen the program WAITS on (blocked in its READ/EXFMT), at endpgm, or signoff — with every intermediate paint, DSPLY line and message in order; steelframex-screen/1 (named fields incl. output fields, subfile rows by rrn, 24 text lines, enabled keys); raw:true = the 5250 WTD datastream. POSIX hosts only (the WSOP display bridge)
Schedulersched_list/add/change/remove, sched_submit_now, sched_wait, jobq_list/create/hold/release/clear, run_chainjob schedule ENTRIES (FRQ(*ONCE) in this engine; other frequencies refuse SFD0084, a past moment refuses SFD1655) and job queues with MAXACT initiators. There is no dependency-graph scheduler on IBM i base OS; the IBM i-correct "cycle" is an ordered chain on a MAXACT(1) queue — run_chain returns every step's joblog and spool in submission order. Nothing CA-7-like is faked
Isolationcreate_workspace, list/get/destroy/reset_workspace, workspace:<id> on any toola child engine (seven OS-allocated ports, scrubbed environment, its own data root + TMPDIR) on a copy of the estate; the live estate stays byte-identical (the gate proves it with parent-side sha256)
Determinismcreate_workspace {clock:{fixed}}; run_batch / call_program / screen_open {clock}the WHOLE child system clock follows: QDATE/QTIME, job stamps, DSPF DATE/TIME specials, the scheduler, SQL CURRENT DATE/TIME/TIMESTAMP, RPG %DATE/%TIME, COBOL ACCEPT FROM DATE. Date-only = that date with the real time of day; full = pinned to the second. A per-job clock on the live lane pins one job (not SQL special registers). Job/spool numbers stay real — reset_workspace {jobNumber} lines them up

Non-goals: no compare/diff/verdict/normalize/mask tool; no code conversion; no CA-7 / Control-M emulation; no MCP SDK, resources, prompts or SSE; no second process on a shared data root.

Worked example — DEPOSIT (the gate's own scenario)

create_workspace  {seed:"clone", clock:{fixed:"2031-02-03 04:05:06"}, jobNumber:500000}          → W1
set_library_list  {workspace:"W1", add:["DEPOSIT"], curlib:"DEPOSIT"}
find_program      {workspace:"W1", lib:"DEPOSIT", pgm:"INTACCR"}     → files + layouts, submittedBy DBNIGHT
decode_records    {workspace:"W1", lib:"DEPOSIT", file:"ACCTMST"}    → rows, rowsText, hex.store, hex.ebcdic, sha256
run_chain         {workspace:"W1", jobq:"DEPOSIT/NITEQ", maxact:1,
                   steps:[{cmd:"CALL DEPOSIT/INTACCR"},{cmd:"CALL DEPOSIT/INTCRED"},
                          {cmd:"CALL DEPOSIT/SVCCHG"},{cmd:"CALL DEPOSIT/GLPOST"}],
                   outputs:["DEPOSIT/ACCTMST","DEPOSIT/TXNJRNL"]}     → 4 joblogs in order + the files after
screen_open       {workspace:"W1", command:"CALL DEPOSIT/TELMENUP", libl:["DEPOSIT"]}   → T1 (the menu)
screen_send       {workspace:"W1", session:"T1", fields:[{name:"MOPT", value:"2"}]}
screen_send       {workspace:"W1", session:"T1", fields:[{name:"IREACCT", value:"ACS0000001"}]}  → subfile rows (rrn)
screen_send       {workspace:"W1", session:"T1", aid:"PAGEDOWN"}  …  F3, F3 → endpgm
export_state      {workspace:"W1", files:["DEPOSIT/ACCTMST"], tables:["DEPOSIT.GLLEDGER"]}
destroy_workspace {id:"W1"}                                          — the live estate never changed

On the private lane the same calls go to POST /mcp?lane=ephemeral (or the bridge with SF_LANE=ephemeral): create_workspace there always makes a RAM-only child and every other tool requires workspace.

Live tool reference

Fetched live from GET /api/mcp/tools — never baked into this page. Each tool shows its description, the read/write/destructive annotation, the route's authority class, the bound HTTP method/path and the full input schema. The "instructions" box is the text an MCP client receives from initialize on that lane. Click a tool to expand.