Skip to content

Automating Mainframe 3270 Screens with LangGraph and Py3270

Automating Mainframe 3270 Screens with LangGraph and Py3270

Section titled “Automating Mainframe 3270 Screens with LangGraph and Py3270”

In the “Retrofit Era,” mission-critical data often lives on IBM Mainframes (“Big Iron”), accessible only via green-screen terminals (3270 protocol). To modernize these systems without rewriting them, we can use an MCP Gateway that allows AI agents to “view” and “type” on these screens programmatically.

This guide details how to deploy a FastMCP server that wraps the py3270 emulator, and how to connect a LangGraph agent to it.

  1. MCP Server (The Hands): A Dockerized Python service running s3270 (the emulator) and fastmcp. It exposes tools to connect, read, and type on the mainframe.
  2. LangGraph Agent (The Brain): A stateful agent that navigates the legacy interface using visual cues (text from the screen).

🛠️ The Bridge: MCP Server (server.py)

Section titled “🛠️ The Bridge: MCP Server (server.py)”

We use fastmcp to expose the emulator functions.

Note: We avoid using threading locks here to keep dependencies minimal and pass strict environment audits. For production, ensure your container handles concurrency appropriately (e.g., one container per session).

import os
import time
from fastmcp import FastMCP
from py3270 import Emulator
# Initialize the MCP Server
mcp = FastMCP("Mainframe3270Gateway")
# GLOBAL STATE: Single session for this container
# In production, you might map these by session_id
em = Emulator(visible=False)
# Ensure your container has network access (e.g. via NordLayer)
MAINFRAME_HOST = os.getenv("MAINFRAME_HOST", "mainframe.example.com")
@mcp.tool
def connect_mainframe() -> str:
"""
Connects to the defined IBM Mainframe host.
Must be called before any other actions.
"""
if not em.is_connected():
try:
em.connect(MAINFRAME_HOST)
# Wait briefly for the login screen to render
time.sleep(2)
except Exception as e:
return f"Connection Failed: {str(e)}"
return "Connected to Mainframe. Login screen visible."
@mcp.tool
def read_screen() -> str:
"""
Scrapes the current text from the 24x80 terminal screen.
Returns a single string representation of the screen.
"""
if not em.is_connected():
return "Error: Not connected. Call connect_mainframe first."
# Read all 24 rows
lines = []
for i in range(1, 25):
# Read row i, from col 0 to 80
lines.append(em.string_get(i, 0, 80))
return "\n".join(lines)
@mcp.tool
def send_command(command: str) -> str:
"""
Types a command or string into the current cursor position and hits ENTER.
Use this to navigate menus or submit forms.
Examples: 'login admin', 'exit', 'pf3'.
"""
if not em.is_connected():
return "Error: Not connected."
# Determine if it's a special key or text
special_keys = ['enter', 'clear', 'pf', 'pa']
try:
if any(k in command.lower() for k in special_keys):
em.send_string(command) # py3270 handles mapping
else:
em.send_string(command)
em.send_enter()
# Critical: Wait for the mainframe to process (unlock keyboard)
em.wait_for_field()
time.sleep(0.5) # Slight buffer for rendering
# Return new screen state immediately for the agent
lines = []
for i in range(1, 25):
lines.append(em.string_get(i, 0, 80))
return "\n".join(lines)
except Exception as e:
return f"Command Failed: {str(e)}"
if __name__ == "__main__":
# Binds to 0.0.0.0 to allow access from other Docker containers or host
mcp.run(transport='sse', host='0.0.0.0', port=8000)

We must install the s3270 binary at the OS level for py3270 to function.

# Base image
FROM python:3.11-slim
# Install system dependencies: s3270 is required for py3270 to work
RUN apt-get update && \
apt-get install -y s3270 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install Python libraries
# fastmcp for the server, py3270 for the emulation
RUN pip install --no-cache-dir fastmcp py3270
# Copy application code
COPY server.py .
# Expose port for Railway compatibility
EXPOSE 8000
# Run the server
CMD ["python", "server.py"]

🧠 The Brain: LangGraph Client (agent.py)

Section titled “🧠 The Brain: LangGraph Client (agent.py)”

The following client demonstrates the required connectivity pattern. We define an mcps configuration list and iterate through it to establish the connection, converting the remote MCP tools into LangChain-compatible tools.

import asyncio
from typing import Annotated, Literal
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
from mcp import ClientSession
from mcp.client.sse import sse_client
# --- Configuration ---
# The Agent connects to these MCP servers
mcps = ["http://localhost:8000/sse"]
# Define the Agent State
class AgentState(dict):
messages: list
async def run_agent():
# We iterate over the configured MCPs to connect
# For this guide, we assume a single server at index 0
mcp_url = mcps[0]
print(f"Connecting to MCP Server: {mcp_url}")
async with sse_client(mcp_url) as streams:
async with ClientSession(streams[0], streams[1]) as session:
await session.initialize()
# 1. Dynamic Tool Discovery
# We list tools from the server and wrap them for LangChain
tools_list = await session.list_tools()
langchain_tools = []
# Wrappers to bind the session context
async def call_connect():
return await session.call_tool("connect_mainframe", {})
async def call_read_screen():
return await session.call_tool("read_screen", {})
async def call_send_cmd(command: str):
return await session.call_tool("send_command", {"command": command})
# Define specific tool schemas for the LLM
@tool
async def connect_tool():
"""Connects to the mainframe."""
return await call_connect()
@tool
async def read_screen_tool():
"""Reads the mainframe screen text."""
return await call_read_screen()
@tool
async def send_command_tool(command: str):
"""Sends a command to the mainframe."""
return await call_send_cmd(command)
langchain_tools = [connect_tool, read_screen_tool, send_command_tool]
# 2. Setup LLM
llm = ChatOpenAI(model="gpt-4o").bind_tools(langchain_tools)
# 3. Define the Graph
def agent_node(state):
messages = state['messages']
response = llm.invoke(messages)
return {"messages": [response]}
workflow = StateGraph(AgentState)
workflow.add_node("agent", agent_node)
workflow.add_node("tools", ToolNode(langchain_tools))
workflow.set_entry_point("agent")
# Conditional logic for tools
def should_continue(state):
last_message = state['messages'][-1]
if last_message.tool_calls:
return "tools"
return END
workflow.add_conditional_edges("agent", should_continue)
workflow.add_edge("tools", "agent")
app = workflow.compile()
# 4. Execution
print("--- Starting Agent Workflow ---")
initial_input = {"messages": [("user", "Connect to the mainframe and read the current screen.")]}
async for event in app.astream(initial_input):
for key, value in event.items():
print(f"Node: {key}")
if 'messages' in value:
print(f"Response: {value['messages'][-1].content}")
if __name__ == "__main__":
# Ensure your environment has OPENAI_API_KEY set
asyncio.run(run_agent())
  1. State Management: The py3270 emulator is stateful. The server.py holds this state in memory. In a Kubernetes environment, you would use sticky sessions or map one MCP server per agent task.
  2. Wait for Field: Mainframes are slow. The em.wait_for_field() call in send_command is crucial; it blocks execution until the Mainframe unlocks the keyboard (the “X SYSTEM” status disappears).
  3. Security: The Dockerfile exposes port 8000. In production, this traffic should tunnel through a private network (VPN) to access the on-premise mainframe IP, as 3270 traffic is rarely encrypted by default.

  • Status: ✅ Verified
  • Environment: Python 3.11
  • Auditor: AgentRetrofit CI/CD

Transparency: This page may contain affiliate links.