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.
🏗️ Architecture
Section titled “🏗️ Architecture”- MCP Server (The Hands): A Dockerized Python service running
s3270(the emulator) andfastmcp. It exposes tools to connect, read, and type on the mainframe. - 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 osimport timefrom fastmcp import FastMCPfrom py3270 import Emulator
# Initialize the MCP Servermcp = FastMCP("Mainframe3270Gateway")
# GLOBAL STATE: Single session for this container# In production, you might map these by session_idem = Emulator(visible=False)
# Ensure your container has network access (e.g. via NordLayer)MAINFRAME_HOST = os.getenv("MAINFRAME_HOST", "mainframe.example.com")
@mcp.tooldef 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.tooldef 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.tooldef 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)🐳 Dockerfile
Section titled “🐳 Dockerfile”We must install the s3270 binary at the OS level for py3270 to function.
# Base imageFROM python:3.11-slim
# Install system dependencies: s3270 is required for py3270 to workRUN 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 emulationRUN pip install --no-cache-dir fastmcp py3270
# Copy application codeCOPY server.py .
# Expose port for Railway compatibilityEXPOSE 8000
# Run the serverCMD ["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 asynciofrom typing import Annotated, Literalfrom langchain_openai import ChatOpenAIfrom langchain_core.tools import toolfrom langgraph.graph import StateGraph, ENDfrom langgraph.prebuilt import ToolNodefrom mcp import ClientSessionfrom mcp.client.sse import sse_client
# --- Configuration ---# The Agent connects to these MCP serversmcps = ["http://localhost:8000/sse"]
# Define the Agent Stateclass 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())Key Implementation Details
Section titled “Key Implementation Details”- State Management: The
py3270emulator is stateful. Theserver.pyholds this state in memory. In a Kubernetes environment, you would use sticky sessions or map one MCP server per agent task. - Wait for Field: Mainframes are slow. The
em.wait_for_field()call insend_commandis crucial; it blocks execution until the Mainframe unlocks the keyboard (the “X SYSTEM” status disappears). - Security: The
Dockerfileexposes 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.
🛡️ Quality Assurance
Section titled “🛡️ Quality Assurance”- Status: ✅ Verified
- Environment: Python 3.11
- Auditor: AgentRetrofit CI/CD
Transparency: This page may contain affiliate links.