[llvm] [mlir] [mlir] Add mlir-opt-repl: interactive MLIR pass pipeline explorer and MCP server (PR #203796)

Maksim Levental via llvm-commits llvm-commits at lists.llvm.org
Sun Jun 14 15:50:17 PDT 2026


https://github.com/makslevental updated https://github.com/llvm/llvm-project/pull/203796

>From 408fa9e50682c717f0edfc75373a3ddffa597a7a Mon Sep 17 00:00:00 2001
From: makslevental <m_levental at apple.com>
Date: Sun, 14 Jun 2026 15:01:23 -0700
Subject: [PATCH] [mlir] Add mlir-opt-repl: interactive MLIR pass pipeline
 explorer and MCP server

A pip-installable Python package (mlir-opt-repl) that provides:
- An interactive terminal REPL for applying mlir-opt passes step by step,
  with history navigation, rewind, and colored unified/side-by-side diffs.
- An MCP (Model Context Protocol) server for use as a Claude Code tool,
  exposing the same functionality over JSON-RPC stdio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply at anthropic.com>
---
 .ci/all_requirements.txt                      | 150 +++++++++-
 mlir/python/requirements.txt                  |   5 +
 mlir/test/mlir-opt-repl/conftest.py           |  84 ++++++
 mlir/test/mlir-opt-repl/lit.local.cfg         |  13 +
 mlir/test/mlir-opt-repl/run-tests.test        |   1 +
 .../mlir-opt-repl/test_cli_and_edge_cases.py  | 150 ++++++++++
 mlir/test/mlir-opt-repl/test_mcp.py           | 281 ++++++++++++++++++
 mlir/test/mlir-opt-repl/test_repl.py          | 203 +++++++++++++
 mlir/tools/mlir-opt-repl/README.md            | 134 +++++++++
 mlir/tools/mlir-opt-repl/pyproject.toml       |  37 +++
 .../src/mlir_opt_repl/__init__.py             |   3 +
 .../src/mlir_opt_repl/__main__.py             |  94 ++++++
 .../mlir-opt-repl/src/mlir_opt_repl/diff.py   |  91 ++++++
 .../mlir-opt-repl/src/mlir_opt_repl/engine.py | 211 +++++++++++++
 .../mlir-opt-repl/src/mlir_opt_repl/mcp.py    | 201 +++++++++++++
 .../mlir-opt-repl/src/mlir_opt_repl/render.py |   7 +
 .../mlir-opt-repl/src/mlir_opt_repl/repl.py   | 206 +++++++++++++
 17 files changed, 1869 insertions(+), 2 deletions(-)
 create mode 100644 mlir/test/mlir-opt-repl/conftest.py
 create mode 100644 mlir/test/mlir-opt-repl/lit.local.cfg
 create mode 100644 mlir/test/mlir-opt-repl/run-tests.test
 create mode 100644 mlir/test/mlir-opt-repl/test_cli_and_edge_cases.py
 create mode 100644 mlir/test/mlir-opt-repl/test_mcp.py
 create mode 100644 mlir/test/mlir-opt-repl/test_repl.py
 create mode 100644 mlir/tools/mlir-opt-repl/README.md
 create mode 100644 mlir/tools/mlir-opt-repl/pyproject.toml
 create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__init__.py
 create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__main__.py
 create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/diff.py
 create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/engine.py
 create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/mcp.py
 create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/render.py
 create mode 100644 mlir/tools/mlir-opt-repl/src/mlir_opt_repl/repl.py

diff --git a/.ci/all_requirements.txt b/.ci/all_requirements.txt
index 1ab2a81b1d1db..6bb95061d657d 100644
--- a/.ci/all_requirements.txt
+++ b/.ci/all_requirements.txt
@@ -181,6 +181,118 @@ charset-normalizer==3.4.3 \
     --hash=sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c \
     --hash=sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9
     # via requests
+click==8.4.1 \
+    --hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \
+    --hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96
+    # via -r mlir/python/requirements.txt
+coverage[toml]==7.14.1 \
+    --hash=sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86 \
+    --hash=sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd \
+    --hash=sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d \
+    --hash=sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5 \
+    --hash=sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42 \
+    --hash=sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de \
+    --hash=sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548 \
+    --hash=sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1 \
+    --hash=sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7 \
+    --hash=sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59 \
+    --hash=sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906 \
+    --hash=sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af \
+    --hash=sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1 \
+    --hash=sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d \
+    --hash=sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1 \
+    --hash=sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be \
+    --hash=sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02 \
+    --hash=sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42 \
+    --hash=sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129 \
+    --hash=sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e \
+    --hash=sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be \
+    --hash=sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e \
+    --hash=sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65 \
+    --hash=sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54 \
+    --hash=sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1 \
+    --hash=sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5 \
+    --hash=sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df \
+    --hash=sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47 \
+    --hash=sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f \
+    --hash=sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf \
+    --hash=sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37 \
+    --hash=sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4 \
+    --hash=sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f \
+    --hash=sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84 \
+    --hash=sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1 \
+    --hash=sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c \
+    --hash=sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8 \
+    --hash=sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e \
+    --hash=sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec \
+    --hash=sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d \
+    --hash=sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54 \
+    --hash=sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890 \
+    --hash=sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b \
+    --hash=sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d \
+    --hash=sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2 \
+    --hash=sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33 \
+    --hash=sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9 \
+    --hash=sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e \
+    --hash=sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6 \
+    --hash=sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce \
+    --hash=sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247 \
+    --hash=sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901 \
+    --hash=sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36 \
+    --hash=sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69 \
+    --hash=sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416 \
+    --hash=sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5 \
+    --hash=sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500 \
+    --hash=sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad \
+    --hash=sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1 \
+    --hash=sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b \
+    --hash=sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b \
+    --hash=sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a \
+    --hash=sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e \
+    --hash=sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee \
+    --hash=sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07 \
+    --hash=sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a \
+    --hash=sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d \
+    --hash=sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c \
+    --hash=sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343 \
+    --hash=sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4 \
+    --hash=sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2 \
+    --hash=sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8 \
+    --hash=sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf \
+    --hash=sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb \
+    --hash=sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c \
+    --hash=sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff \
+    --hash=sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e \
+    --hash=sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550 \
+    --hash=sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860 \
+    --hash=sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793 \
+    --hash=sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f \
+    --hash=sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851 \
+    --hash=sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7 \
+    --hash=sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332 \
+    --hash=sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b \
+    --hash=sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2 \
+    --hash=sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d \
+    --hash=sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a \
+    --hash=sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef \
+    --hash=sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474 \
+    --hash=sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee \
+    --hash=sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43 \
+    --hash=sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034 \
+    --hash=sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3 \
+    --hash=sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c \
+    --hash=sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d \
+    --hash=sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7 \
+    --hash=sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e \
+    --hash=sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d \
+    --hash=sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4 \
+    --hash=sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9 \
+    --hash=sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52 \
+    --hash=sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a \
+    --hash=sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c \
+    --hash=sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253 \
+    --hash=sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c
+    # via pytest-cov
 cryptography==46.0.3 \
     --hash=sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217 \
     --hash=sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d \
@@ -239,6 +351,10 @@ cryptography==46.0.3 \
     # via
     #   -r lldb/test/requirements.txt
     #   pyjwt
+execnet==2.1.2 \
+    --hash=sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd \
+    --hash=sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec
+    # via pytest-xdist
 google-api-core==2.25.1 \
     --hash=sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7 \
     --hash=sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8
@@ -310,6 +426,10 @@ idna==3.10 \
     --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
     --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
     # via requests
+iniconfig==2.3.0 \
+    --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
+    --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
+    # via pytest
 junitparser==3.2.0 \
     --hash=sha256:b05e89c27e7b74b3c563a078d6e055d95cf397444f8f689b0ca616ebda0b3c65 \
     --hash=sha256:e14fdc0a999edfc15889b637390e8ef6ca09a49532416d3bd562857d42d4b96d
@@ -404,11 +524,19 @@ numpy==2.1.2 \
 packaging==25.0 \
     --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
     --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
-    # via -r lldb/test/requirements.txt
+    # via
+    #   -r lldb/test/requirements.txt
+    #   pytest
 pexpect==4.9.0 ; sys_platform != "win32" \
     --hash=sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523 \
     --hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f
     # via -r lldb/test/requirements.txt
+pluggy==1.6.0 \
+    --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
+    --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
+    # via
+    #   pytest
+    #   pytest-cov
 proto-plus==1.26.1 \
     --hash=sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66 \
     --hash=sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012
@@ -461,6 +589,10 @@ pygithub==2.8.1 \
     --hash=sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0 \
     --hash=sha256:341b7c78521cb07324ff670afd1baa2bf5c286f8d9fd302c1798ba594a5400c9
     # via -r .ci/requirements.txt
+pygments==2.20.0 \
+    --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
+    --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
+    # via pytest
 pyjwt[crypto]==2.10.1 \
     --hash=sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953 \
     --hash=sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb
@@ -494,6 +626,21 @@ pynacl==1.6.0 \
     --hash=sha256:f46386c24a65383a9081d68e9c2de909b1834ec74ff3013271f1bca9c2d233eb \
     --hash=sha256:f4b3824920e206b4f52abd7de621ea7a44fd3cb5c8daceb7c3612345dfc54f2e
     # via pygithub
+pytest==9.1.0 \
+    --hash=sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c \
+    --hash=sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32
+    # via
+    #   -r mlir/python/requirements.txt
+    #   pytest-cov
+    #   pytest-xdist
+pytest-cov==7.1.0 \
+    --hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
+    --hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
+    # via -r mlir/python/requirements.txt
+pytest-xdist==3.8.0 \
+    --hash=sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 \
+    --hash=sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1
+    # via -r mlir/python/requirements.txt
 pyyaml==6.0.1 \
     --hash=sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5 \
     --hash=sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc \
@@ -580,7 +727,6 @@ typing-extensions==4.15.0 \
     --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
     # via
     #   -r mlir/python/requirements.txt
-    #   cryptography
     #   pygithub
 urllib3==2.5.0 \
     --hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \
diff --git a/mlir/python/requirements.txt b/mlir/python/requirements.txt
index 538929e1bbfd2..65e430c3813f2 100644
--- a/mlir/python/requirements.txt
+++ b/mlir/python/requirements.txt
@@ -5,3 +5,8 @@ typing_extensions>=4.12.2
 # RUN dependencies
 numpy>=2.1.0, <=2.1.2
 ml_dtypes>=0.5.0, <=0.6.0
+click>=8.2.1
+# TEST dependencies
+pytest>=9.0.3
+pytest-cov>=7.1.0
+pytest-xdist>=3.8.0
diff --git a/mlir/test/mlir-opt-repl/conftest.py b/mlir/test/mlir-opt-repl/conftest.py
new file mode 100644
index 0000000000000..39972267255a4
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/conftest.py
@@ -0,0 +1,84 @@
+import json
+import re
+import sys
+from contextlib import contextmanager
+from io import StringIO
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(
+    0, str(Path(__file__).parent.parent.parent / "tools" / "mlir-opt-repl" / "src")
+)
+
+import mlir_opt_repl.engine as engine
+from mlir_opt_repl.mcp import mcp_main
+from mlir_opt_repl.repl import interactive_main
+
+SAMPLE_MLIR = "func.func @test(%arg0: f32, %arg1: f32) -> f32 { %0 = arith.addf %arg0, %arg1 : f32 return %0 : f32 }"
+INIT_MSG = {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}
+
+
+ at pytest.fixture(autouse=True)
+def reset_engine():
+    engine.current_ir = None
+    engine.ir_history = []
+    yield
+    engine.current_ir = None
+    engine.ir_history = []
+
+
+ at contextmanager
+def capture_stdio(input_text):
+    old_stdin, old_stdout = sys.stdin, sys.stdout
+    sys.stdin = StringIO(input_text)
+    sys.stdout = StringIO()
+    try:
+        yield sys.stdout
+    finally:
+        sys.stdin, sys.stdout = old_stdin, old_stdout
+
+
+def run_mcp(*messages):
+    input_text = ""
+    for m in messages:
+        text = json.dumps(m)
+        input_text += f"Content-Length: {len(text.encode())}\r\n\r\n{text}"
+
+    with capture_stdio(input_text) as stdout:
+        try:
+            mcp_main()
+        except Exception:
+            pass
+    return stdout.getvalue()
+
+
+def run_repl(input_text):
+    with capture_stdio(input_text) as stdout:
+        try:
+            interactive_main()
+        except (EOFError, SystemExit):
+            pass
+    return stdout.getvalue()
+
+
+def parse_responses(output):
+    responses = []
+    for m in re.finditer(r"Content-Length: (\d+)\r?\n\r?\n", output):
+        start = m.end()
+        length = int(m.group(1))
+        body = output[start : start + length]
+        responses.append(json.loads(body))
+    return responses
+
+
+def tool_call(id, name, arguments=None):
+    return {
+        "jsonrpc": "2.0",
+        "id": id,
+        "method": "tools/call",
+        "params": {
+            "name": name,
+            "arguments": arguments or {},
+        },
+    }
diff --git a/mlir/test/mlir-opt-repl/lit.local.cfg b/mlir/test/mlir-opt-repl/lit.local.cfg
new file mode 100644
index 0000000000000..a911833dfd4ad
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/lit.local.cfg
@@ -0,0 +1,13 @@
+import os
+
+src_root = os.path.join(config.mlir_src_root, "tools", "mlir-opt-repl", "src")
+pyproject = os.path.join(config.mlir_src_root, "tools", "mlir-opt-repl", "pyproject.toml")
+config.environment["PYTHONPATH"] = src_root
+config.environment["MLIR_OPT"] = os.path.join(config.mlir_tools_dir, "mlir-opt")
+
+config.substitutions.insert(0, ("%pyproject", pyproject))
+
+config.suffixes = [".test"]
+
+if not config.enable_bindings_python:
+    config.unsupported = True
diff --git a/mlir/test/mlir-opt-repl/run-tests.test b/mlir/test/mlir-opt-repl/run-tests.test
new file mode 100644
index 0000000000000..4500591f9d7f9
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/run-tests.test
@@ -0,0 +1 @@
+// RUN: %python -m pytest %S -n auto -q --cov=mlir_opt_repl --cov-config=%pyproject --cov-report=term-missing --cov-fail-under=100
diff --git a/mlir/test/mlir-opt-repl/test_cli_and_edge_cases.py b/mlir/test/mlir-opt-repl/test_cli_and_edge_cases.py
new file mode 100644
index 0000000000000..f92fc171fe9a6
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/test_cli_and_edge_cases.py
@@ -0,0 +1,150 @@
+"""Tests for the click CLI entry point and edge cases in engine/diff."""
+
+import json
+import subprocess
+from unittest.mock import patch
+
+from click.testing import CliRunner
+
+import mlir_opt_repl.engine as engine
+from conftest import capture_stdio
+from mlir_opt_repl.__main__ import cli
+from mlir_opt_repl.diff import render_side_by_side, render_unified_diff
+from mlir_opt_repl.mcp import mcp_main
+from mlir_opt_repl.repl import interactive_main
+
+
+class TestClickCLI:
+    def test_help(self):
+        result = CliRunner().invoke(cli, ["--help"])
+        assert result.exit_code == 0
+        assert "mcp" in result.output
+        assert "repl" in result.output
+
+    def test_mcp_help(self):
+        result = CliRunner().invoke(cli, ["mcp", "--help"])
+        assert result.exit_code == 0
+        assert "MCP" in result.output
+
+    def test_repl_help(self):
+        result = CliRunner().invoke(cli, ["repl", "--help"])
+        assert result.exit_code == 0
+        assert "load" in result.output
+
+    def test_default_is_repl(self):
+        result = CliRunner().invoke(cli, [], input="quit\n")
+        assert "mlir-opt-repl" in result.output
+
+    def test_mcp_subcommand(self):
+        msg = json.dumps(
+            {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}
+        )
+        input_text = f"Content-Length: {len(msg.encode())}\r\n\r\n{msg}"
+        result = CliRunner().invoke(cli, ["mcp"], input=input_text)
+        assert "mlir-opt-repl" in result.output
+
+
+class TestEngineEdgeCases:
+    def test_mlir_opt_not_found(self):
+        old = engine.MLIR_OPT
+        engine.MLIR_OPT = "/nonexistent/mlir-opt"
+        try:
+            result = engine.handle_tool_call(
+                "run_pipeline", {"mlir": "module {}", "passes": ["canonicalize"]}
+            )
+            assert result["isError"]
+            assert "not found" in result["content"][0]["text"]
+        finally:
+            engine.MLIR_OPT = old
+
+    def test_check_mlir_opt_exits(self):
+        import pytest
+
+        old = engine.MLIR_OPT
+        engine.MLIR_OPT = "/nonexistent/mlir-opt"
+        try:
+            with pytest.raises(SystemExit):
+                engine.check_mlir_opt()
+        finally:
+            engine.MLIR_OPT = old
+
+    def test_check_mlir_opt_passes(self):
+        engine.check_mlir_opt()
+
+    def test_list_passes_error(self):
+        old = engine.MLIR_OPT
+        engine.MLIR_OPT = "/nonexistent/mlir-opt"
+        try:
+            passes = engine.list_passes()
+            assert len(passes) == 1
+            assert "error" in passes[0]
+        finally:
+            engine.MLIR_OPT = old
+
+    def test_timeout(self):
+        with patch(
+            "mlir_opt_repl.engine.subprocess.run",
+            side_effect=subprocess.TimeoutExpired("cmd", 30),
+        ):
+            result = engine.handle_tool_call(
+                "run_pipeline", {"mlir": "module {}", "passes": ["canonicalize"]}
+            )
+        assert result["isError"]
+        assert "timed out" in result["content"][0]["text"]
+
+
+class TestMCPEdgeCases:
+    def test_empty_content_length(self):
+        with capture_stdio("Content-Length: 0\r\n\r\n") as stdout:
+            try:
+                mcp_main()
+            except Exception:
+                pass
+        assert stdout.getvalue() == ""
+
+
+class TestReplLoadEOF:
+    def test_load_stdin_eof(self):
+        with capture_stdio("load -\nfunc.func @f() { return }") as stdout:
+            try:
+                interactive_main()
+            except (EOFError, SystemExit):
+                pass
+        assert "Loaded" in stdout.getvalue()
+
+
+class TestDiffEdgeCases:
+    def test_non_pretty_side_by_side(self):
+        result = render_side_by_side(
+            ["a", "b"], ["a", "c"], "left", "right", width=60, pretty=False
+        )
+        assert "|" in result
+
+    def test_delete_only(self):
+        result = render_side_by_side(
+            ["a", "b", "c"], ["a"], "left", "right", width=80, pretty=True
+        )
+        assert "b" in result and "c" in result
+
+    def test_insert_only(self):
+        result = render_side_by_side(
+            ["a"], ["a", "b", "c"], "left", "right", width=80, pretty=True
+        )
+        assert "b" in result and "c" in result
+
+    def test_non_pretty_unified(self):
+        result = render_unified_diff(["a", "b"], ["a", "c"], "f1", "f2", pretty=False)
+        assert "-b" in result and "+c" in result
+        assert "\033[" not in result
+
+    def test_truncation(self):
+        result = render_side_by_side(
+            ["x" * 200], ["short"], "left", "right", width=60, pretty=False
+        )
+        assert "…" in result
+
+    def test_replace_uneven_more_left(self):
+        result = render_side_by_side(
+            ["a", "b", "c"], ["x"], "left", "right", width=60, pretty=False
+        )
+        assert "b" in result and "c" in result
diff --git a/mlir/test/mlir-opt-repl/test_mcp.py b/mlir/test/mlir-opt-repl/test_mcp.py
new file mode 100644
index 0000000000000..1a70aabe8921e
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/test_mcp.py
@@ -0,0 +1,281 @@
+"""Tests for the MCP server protocol."""
+
+from conftest import INIT_MSG, SAMPLE_MLIR, parse_responses, run_mcp, tool_call
+
+
+class TestInitialize:
+    def test_response(self):
+        output = run_mcp(INIT_MSG)
+        r = parse_responses(output)[0]
+        assert r["id"] == 0
+        assert r["result"]["serverInfo"]["name"] == "mlir-opt-repl"
+        assert r["result"]["protocolVersion"] == "2024-11-05"
+        assert r["result"]["capabilities"]["tools"]["listChanged"] is False
+
+
+class TestToolsList:
+    def test_all_tools_present(self):
+        output = run_mcp(
+            INIT_MSG, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}
+        )
+        tools = parse_responses(output)[1]["result"]["tools"]
+        names = {t["name"] for t in tools}
+        assert names == {
+            "run_pipeline",
+            "chain_pipeline",
+            "get_current_ir",
+            "reset",
+            "list_passes",
+            "rewind",
+            "history",
+        }
+
+
+class TestRunPipeline:
+    def test_basic(self):
+        output = run_mcp(
+            INIT_MSG,
+            tool_call(
+                1,
+                "run_pipeline",
+                {
+                    "mlir": SAMPLE_MLIR,
+                    "passes": ["convert-arith-to-llvm", "convert-func-to-llvm"],
+                },
+            ),
+        )
+        text = parse_responses(output)[1]["result"]["content"][0]["text"]
+        assert "llvm.func" in text
+        assert "llvm.fadd" in text
+
+    def test_invalid_mlir(self):
+        output = run_mcp(
+            INIT_MSG,
+            tool_call(
+                1, "run_pipeline", {"mlir": "not valid", "passes": ["canonicalize"]}
+            ),
+        )
+        r = parse_responses(output)[1]["result"]
+        assert r["isError"] is True
+
+    def test_extra_args(self):
+        output = run_mcp(
+            INIT_MSG,
+            tool_call(
+                1,
+                "run_pipeline",
+                {
+                    "mlir": "func.func @f() { return }",
+                    "passes": ["canonicalize"],
+                    "extra_args": ["allow-unregistered-dialect"],
+                },
+            ),
+        )
+        r = parse_responses(output)[1]["result"]
+        assert "isError" not in r
+
+
+class TestChainPipeline:
+    def test_chain(self):
+        output = run_mcp(
+            INIT_MSG,
+            tool_call(
+                1, "run_pipeline", {"mlir": SAMPLE_MLIR, "passes": ["canonicalize"]}
+            ),
+            tool_call(
+                2,
+                "chain_pipeline",
+                {"passes": ["convert-arith-to-llvm", "convert-func-to-llvm"]},
+            ),
+        )
+        text = parse_responses(output)[2]["result"]["content"][0]["text"]
+        assert "llvm.func" in text
+
+    def test_no_state(self):
+        output = run_mcp(
+            INIT_MSG, tool_call(1, "chain_pipeline", {"passes": ["canonicalize"]})
+        )
+        r = parse_responses(output)[1]["result"]
+        assert r["isError"] is True
+        assert "no current IR state" in r["content"][0]["text"]
+
+    def test_error_preserves_state(self):
+        output = run_mcp(
+            INIT_MSG,
+            tool_call(
+                1, "run_pipeline", {"mlir": SAMPLE_MLIR, "passes": ["canonicalize"]}
+            ),
+            tool_call(2, "chain_pipeline", {"passes": ["nonexistent-pass-xyz"]}),
+            tool_call(3, "get_current_ir"),
+        )
+        responses = parse_responses(output)
+        assert responses[2]["result"]["isError"] is True
+        assert "arith.addf" in responses[3]["result"]["content"][0]["text"]
+
+
+class TestGetCurrentIR:
+    def test_no_state(self):
+        output = run_mcp(INIT_MSG, tool_call(1, "get_current_ir"))
+        assert (
+            "(no IR state set)"
+            in parse_responses(output)[1]["result"]["content"][0]["text"]
+        )
+
+    def test_with_state(self):
+        output = run_mcp(
+            INIT_MSG,
+            tool_call(
+                1,
+                "run_pipeline",
+                {"mlir": "func.func @f() { return }", "passes": ["canonicalize"]},
+            ),
+            tool_call(2, "get_current_ir"),
+        )
+        assert (
+            "func.func @f" in parse_responses(output)[2]["result"]["content"][0]["text"]
+        )
+
+
+class TestReset:
+    def test_clears_state(self):
+        output = run_mcp(
+            INIT_MSG,
+            tool_call(
+                1,
+                "run_pipeline",
+                {"mlir": "func.func @f() { return }", "passes": ["canonicalize"]},
+            ),
+            tool_call(2, "reset"),
+            tool_call(3, "get_current_ir"),
+        )
+        responses = parse_responses(output)
+        assert "IR state cleared" in responses[2]["result"]["content"][0]["text"]
+        assert "(no IR state set)" in responses[3]["result"]["content"][0]["text"]
+
+
+class TestRewind:
+    def test_rewind_one(self):
+        output = run_mcp(
+            INIT_MSG,
+            tool_call(
+                1, "run_pipeline", {"mlir": SAMPLE_MLIR, "passes": ["canonicalize"]}
+            ),
+            tool_call(2, "chain_pipeline", {"passes": ["convert-arith-to-llvm"]}),
+            tool_call(3, "rewind", {"steps": 1}),
+        )
+        text = parse_responses(output)[3]["result"]["content"][0]["text"]
+        assert "Rewound 1 step(s)" in text
+        assert "arith.addf" in text
+
+    def test_no_history(self):
+        output = run_mcp(INIT_MSG, tool_call(1, "rewind", {"steps": 1}))
+        r = parse_responses(output)[1]["result"]
+        assert r["isError"] is True
+
+    def test_past_beginning(self):
+        output = run_mcp(
+            INIT_MSG,
+            tool_call(
+                1,
+                "run_pipeline",
+                {"mlir": "func.func @f() { return }", "passes": ["canonicalize"]},
+            ),
+            tool_call(2, "rewind", {"steps": 99}),
+        )
+        assert (
+            "Rewound to beginning"
+            in parse_responses(output)[2]["result"]["content"][0]["text"]
+        )
+
+
+class TestHistory:
+    def _setup(self):
+        return [
+            INIT_MSG,
+            tool_call(
+                1, "run_pipeline", {"mlir": SAMPLE_MLIR, "passes": ["canonicalize"]}
+            ),
+            tool_call(2, "chain_pipeline", {"passes": ["convert-arith-to-llvm"]}),
+        ]
+
+    def test_no_history(self):
+        output = run_mcp(INIT_MSG, tool_call(1, "history"))
+        assert (
+            "(no history)" in parse_responses(output)[1]["result"]["content"][0]["text"]
+        )
+
+    def test_unified(self):
+        output = run_mcp(*self._setup(), tool_call(3, "history", {"format": "unified"}))
+        text = parse_responses(output)[3]["result"]["content"][0]["text"]
+        assert "---" in text and "+++" in text
+
+    def test_side_by_side(self):
+        output = run_mcp(
+            *self._setup(),
+            tool_call(3, "history", {"format": "side_by_side", "width": 80}),
+        )
+        text = parse_responses(output)[3]["result"]["content"][0]["text"]
+        assert "--canonicalize" in text and "--convert-arith-to-llvm" in text
+
+    def test_show_ir(self):
+        output = run_mcp(*self._setup(), tool_call(3, "history", {"show_ir": True}))
+        text = parse_responses(output)[3]["result"]["content"][0]["text"]
+        assert "[0] initial" in text and "arith.addf" in text
+
+    def test_pretty_unified(self):
+        output = run_mcp(
+            *self._setup(),
+            tool_call(3, "history", {"format": "unified", "pretty": True}),
+        )
+        text = parse_responses(output)[3]["result"]["content"][0]["text"]
+        assert "\033[" in text
+
+    def test_pretty_side_by_side(self):
+        output = run_mcp(
+            *self._setup(),
+            tool_call(
+                3, "history", {"format": "side_by_side", "pretty": True, "width": 80}
+            ),
+        )
+        text = parse_responses(output)[3]["result"]["content"][0]["text"]
+        assert "\033[" in text
+
+
+class TestListPasses:
+    def test_filter(self):
+        output = run_mcp(
+            INIT_MSG, tool_call(1, "list_passes", {"filter": "arith-to-llvm"})
+        )
+        assert (
+            "convert-arith-to-llvm"
+            in parse_responses(output)[1]["result"]["content"][0]["text"]
+        )
+
+    def test_no_match(self):
+        output = run_mcp(
+            INIT_MSG, tool_call(1, "list_passes", {"filter": "zzz-nonexistent"})
+        )
+        assert (
+            "(no passes matched)"
+            in parse_responses(output)[1]["result"]["content"][0]["text"]
+        )
+
+
+class TestErrors:
+    def test_unknown_tool(self):
+        output = run_mcp(INIT_MSG, tool_call(1, "unknown_tool"))
+        r = parse_responses(output)[1]["result"]
+        assert r["isError"] is True
+        assert "Unknown tool" in r["content"][0]["text"]
+
+    def test_unknown_method(self):
+        output = run_mcp(
+            INIT_MSG, {"jsonrpc": "2.0", "id": 1, "method": "bad/method", "params": {}}
+        )
+        assert "Method not found" in parse_responses(output)[1]["error"]["message"]
+
+    def test_notification_no_response(self):
+        output = run_mcp(
+            INIT_MSG, {"jsonrpc": "2.0", "method": "notifications/initialized"}
+        )
+        assert len(parse_responses(output)) == 1
diff --git a/mlir/test/mlir-opt-repl/test_repl.py b/mlir/test/mlir-opt-repl/test_repl.py
new file mode 100644
index 0000000000000..be4f214ae0cff
--- /dev/null
+++ b/mlir/test/mlir-opt-repl/test_repl.py
@@ -0,0 +1,203 @@
+"""Tests for the interactive REPL."""
+
+from conftest import SAMPLE_MLIR, run_repl
+
+LOAD_SAMPLE = f"load -\n{SAMPLE_MLIR}\n\n"
+
+
+class TestHelp:
+    def test_shows_commands(self):
+        output = run_repl("help\nquit\n")
+        for cmd in ("load", "run", "diff", "sbs", "rewind", "quit"):
+            assert cmd in output
+
+
+class TestLoad:
+    def test_stdin(self):
+        output = run_repl("load -\nfunc.func @f() { return }\n\nir\nquit\n")
+        assert "Loaded" in output
+        assert "func.func @f" in output
+
+    def test_multiline(self):
+        output = run_repl(
+            "load -\nmodule {\n  func.func @f() {\n    return\n  }\n}\n\nir\nquit\n"
+        )
+        assert "Loaded" in output
+        assert "module {" in output
+
+    def test_file(self, tmp_path):
+        f = tmp_path / "test.mlir"
+        f.write_text("module {}")
+        output = run_repl(f"load {f}\nir\nquit\n")
+        assert "Loaded" in output
+        assert "module {}" in output
+
+    def test_file_not_found(self):
+        output = run_repl("load /nonexistent/path.mlir\nquit\n")
+        assert "File not found" in output
+
+    def test_no_arg(self):
+        output = run_repl("load\nquit\n")
+        assert "Usage:" in output
+
+    def test_invalid_mlir(self):
+        output = run_repl("load -\nhello\n\nquit\n")
+        assert "Invalid MLIR" in output
+
+    def test_stdin_eof(self):
+        output = run_repl("load -\nfunc.func @f() { return }")
+        assert "Loaded" in output
+
+
+class TestRun:
+    def test_basic(self):
+        output = run_repl(LOAD_SAMPLE + "run canonicalize\nquit\n")
+        assert "arith.addf" in output
+
+    def test_multiple_passes(self):
+        output = run_repl(
+            LOAD_SAMPLE + "run convert-arith-to-llvm convert-func-to-llvm\nquit\n"
+        )
+        assert "llvm.fadd" in output
+
+    def test_no_ir(self):
+        output = run_repl("run canonicalize\nquit\n")
+        assert "No IR loaded" in output
+
+    def test_no_arg(self):
+        output = run_repl("run\nquit\n")
+        assert "Usage:" in output
+
+    def test_invalid_pass(self):
+        output = run_repl(LOAD_SAMPLE + "run nonexistent-pass-xyz\nquit\n")
+        assert "Current IR unchanged" in output
+
+
+class TestHistory:
+    def test_shows_steps(self):
+        output = run_repl(
+            LOAD_SAMPLE + "run canonicalize\nrun convert-arith-to-llvm\nhistory\nquit\n"
+        )
+        assert "initial" in output
+        assert "--canonicalize" in output
+        assert "--convert-arith-to-llvm" in output
+        assert "<-- current" in output
+
+    def test_no_history(self):
+        output = run_repl("history\nquit\n")
+        assert "(no history)" in output
+
+
+class TestDiff:
+    def test_last_step(self):
+        output = run_repl(
+            LOAD_SAMPLE + "run canonicalize\nrun convert-arith-to-llvm\ndiff\nquit\n"
+        )
+        assert "---" in output
+        assert "+++" in output
+
+    def test_with_indices(self):
+        output = run_repl(
+            LOAD_SAMPLE
+            + "run canonicalize\nrun convert-arith-to-llvm\nrun convert-func-to-llvm\ndiff 0 2\nquit\n"
+        )
+        assert "--- initial" in output
+
+    def test_no_history(self):
+        output = run_repl("diff\nquit\n")
+        assert "need at least 2 history entries" in output
+
+    def test_invalid_indices(self):
+        output = run_repl(
+            LOAD_SAMPLE
+            + "run canonicalize\nrun convert-arith-to-llvm\ndiff 0 99\nquit\n"
+        )
+        assert "Invalid indices" in output
+
+
+class TestSbs:
+    def test_last_step(self):
+        output = run_repl(
+            LOAD_SAMPLE + "run canonicalize\nrun convert-arith-to-llvm\nsbs\nquit\n"
+        )
+        assert "--canonicalize" in output
+        assert "--convert-arith-to-llvm" in output
+
+    def test_with_indices(self):
+        output = run_repl(
+            LOAD_SAMPLE
+            + "run canonicalize\nrun convert-arith-to-llvm\nrun convert-func-to-llvm\nsbs 0 2\nquit\n"
+        )
+        assert "initial" in output
+
+    def test_no_history(self):
+        output = run_repl("sbs\nquit\n")
+        assert "need at least 2 history entries" in output
+
+    def test_invalid_indices(self):
+        output = run_repl(
+            LOAD_SAMPLE
+            + "run canonicalize\nrun convert-arith-to-llvm\nsbs 0 99\nquit\n"
+        )
+        assert "Invalid indices" in output
+
+
+class TestRewind:
+    def test_rewind_one(self):
+        output = run_repl(
+            LOAD_SAMPLE + "run canonicalize\nrun convert-arith-to-llvm\nrewind\nquit\n"
+        )
+        assert "Rewound 1 step(s)" in output
+
+    def test_rewind_past_beginning(self):
+        output = run_repl(LOAD_SAMPLE + "run canonicalize\nrewind 99\nquit\n")
+        assert "Rewound to beginning" in output
+
+    def test_no_history(self):
+        output = run_repl("rewind\nquit\n")
+        assert "No history to rewind" in output
+
+
+class TestReset:
+    def test_clears(self):
+        output = run_repl(LOAD_SAMPLE + "run canonicalize\nreset\nir\nquit\n")
+        assert "IR state cleared" in output
+        assert "(no IR state set)" in output
+
+
+class TestIR:
+    def test_no_state(self):
+        output = run_repl("ir\nquit\n")
+        assert "(no IR state set)" in output
+
+    def test_with_state(self):
+        output = run_repl(LOAD_SAMPLE + "ir\nquit\n")
+        assert "arith.addf" in output
+
+
+class TestPasses:
+    def test_filter(self):
+        output = run_repl("passes arith-to-llvm\nquit\n")
+        assert "convert-arith-to-llvm" in output
+
+    def test_no_match(self):
+        output = run_repl("passes zzz-nonexistent\nquit\n")
+        assert "(no passes matched)" in output
+
+
+class TestMisc:
+    def test_unknown_command(self):
+        output = run_repl("badcommand\nquit\n")
+        assert "Unknown command" in output
+
+    def test_eof_exits(self):
+        output = run_repl("")
+        assert "mlir-opt-repl" in output
+
+    def test_empty_lines_ignored(self):
+        output = run_repl("\n\n\nquit\n")
+        assert "mlir-opt-repl" in output
+
+    def test_exit_alias(self):
+        output = run_repl("exit\n")
+        assert "mlir-opt-repl" in output
diff --git a/mlir/tools/mlir-opt-repl/README.md b/mlir/tools/mlir-opt-repl/README.md
new file mode 100644
index 0000000000000..9eec15b3c3e06
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/README.md
@@ -0,0 +1,134 @@
+# mlir-opt-repl
+
+An interactive MLIR pass pipeline explorer. Feed MLIR through passes
+incrementally, inspect intermediate states, rewind to try different lowering
+paths, and diff what each pass changed.
+
+Also functions as an MCP (Model Context Protocol) server for Claude Code.
+
+## Installation
+
+```bash
+pip install mlir-opt-repl
+```
+
+`mlir-opt` must be on your PATH, or set via `MLIR_OPT`:
+
+```bash
+export MLIR_OPT=/path/to/llvm-project/build/bin/mlir-opt
+```
+
+## Interactive REPL (default)
+
+```bash
+mlir-opt-repl
+```
+
+Commands: `load`, `run`, `ir`, `history`, `diff`, `sbs`, `rewind`, `reset`, `passes`, `quit`.
+Diffs are rendered with ANSI colors (red/green for changes, dim for context).
+
+```
+mlir-opt-repl> load test.mlir
+mlir-opt-repl> run canonicalize
+mlir-opt-repl> run convert-arith-to-llvm
+mlir-opt-repl> diff
+mlir-opt-repl> sbs 0 2
+mlir-opt-repl> rewind 1
+```
+
+## MCP Server (for Claude Code)
+
+```bash
+mlir-opt-repl mcp
+```
+
+To configure Claude Code, add to `.claude/settings.local.json`:
+
+```json
+{
+  "mcpServers": {
+    "mlir-opt-repl": {
+      "command": "mlir-opt-repl",
+      "args": ["mcp"],
+      "env": {
+        "MLIR_OPT": "/path/to/build/bin/mlir-opt"
+      }
+    }
+  }
+}
+```
+
+For development without installing:
+
+```json
+{
+  "mcpServers": {
+    "mlir-opt-repl": {
+      "command": "python3",
+      "args": ["-m", "mlir_opt_repl", "mcp"],
+      "env": {
+        "PYTHONPATH": "/path/to/llvm-project/mlir/tools/mlir-opt-repl/src",
+        "MLIR_OPT": "/path/to/build/bin/mlir-opt"
+      }
+    }
+  }
+}
+```
+
+### MCP Tools
+
+| Tool | Description |
+|------|-------------|
+| `run_pipeline` | Run MLIR source through passes, stores result as current state |
+| `chain_pipeline` | Apply additional passes to the current IR state |
+| `get_current_ir` | Show the current IR without running passes |
+| `reset` | Clear current IR state and history |
+| `rewind` | Undo the last N pass applications |
+| `history` | Show pass application timeline, with unified or side-by-side diffs |
+| `list_passes` | List available mlir-opt passes with optional filter |
+
+### Example MCP Session
+
+```
+run_pipeline(mlir="func.func @f(...) ...", passes=["canonicalize"])
+  → canonicalized IR (saved as state)
+
+chain_pipeline(passes=["convert-arith-to-llvm"])
+  → arith ops converted to LLVM dialect
+
+history(format="unified")
+  → shows unified diff between each step
+
+rewind(steps=1)
+  → back to post-canonicalize state, try a different path
+
+history(format="side_by_side", width=120)
+  → two-column comparison of each step
+```
+
+## Project Structure
+
+```
+pyproject.toml
+src/mlir_opt_repl/
+  __init__.py
+  __main__.py    — Click CLI: mlir-opt-repl [mcp|repl]
+  engine.py      — Core state + logic: run_mlir_opt, list_passes, handle_tool_call
+  mcp.py         — MCP protocol: TOOLS schema, send/recv, dispatch
+  repl.py        — Interactive terminal REPL
+  diff.py        — Side-by-side and unified diff renderers
+  render.py      — ANSI color constants
+```
+
+## Testing
+
+Tests live in `mlir/test/mlir-opt-repl/` and use pytest with 100% line coverage:
+
+```bash
+PYTHONPATH=mlir/tools/mlir-opt-repl/src \
+MLIR_OPT=/path/to/build/bin/mlir-opt \
+  python3 -m pytest mlir/test/mlir-opt-repl \
+    --cov=mlir_opt_repl \
+    --cov-config=mlir/tools/mlir-opt-repl/pyproject.toml \
+    --cov-fail-under=100
+```
diff --git a/mlir/tools/mlir-opt-repl/pyproject.toml b/mlir/tools/mlir-opt-repl/pyproject.toml
new file mode 100644
index 0000000000000..897c17cbb5276
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/pyproject.toml
@@ -0,0 +1,37 @@
+[build-system]
+requires = ["setuptools>=68.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "mlir-opt-repl"
+version = "0.1.0"
+description = "Interactive MLIR pass pipeline explorer — MCP server and terminal REPL"
+readme = "README.md"
+license = "Apache-2.0"
+requires-python = ">=3.9"
+dependencies = ["click>=8.0"]
+authors = [
+    {name = "Maksim Levental", email = "maksim.levental at gmail.com"},
+]
+keywords = ["mlir", "llvm", "mcp", "repl", "compiler"]
+classifiers = [
+    "Development Status :: 3 - Alpha",
+    "Intended Audience :: Developers",
+    "Topic :: Software Development :: Compilers",
+    "Programming Language :: Python :: 3",
+]
+
+[project.scripts]
+mlir-opt-repl = "mlir_opt_repl.__main__:main"
+
+[project.urls]
+Repository = "https://github.com/llvm/llvm-project/tree/main/mlir/tools/mlir-opt-repl"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.coverage.report]
+exclude_lines = [
+    "if __name__ == .__main__.",
+    "^def main\\(\\):",
+]
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__init__.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__init__.py
new file mode 100644
index 0000000000000..811b20444e0a5
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__init__.py
@@ -0,0 +1,3 @@
+from mlir_opt_repl.render import *  # noqa: F401,F403
+from mlir_opt_repl.diff import *  # noqa: F401,F403
+from mlir_opt_repl.engine import *  # noqa: F401,F403
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__main__.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__main__.py
new file mode 100644
index 0000000000000..b142029b586bb
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/__main__.py
@@ -0,0 +1,94 @@
+"""Entry point for mlir-opt-repl.
+
+Provides two modes:
+  - MCP server: speaks Model Context Protocol over stdio,
+    for use as a Claude Code tool server.
+  - Interactive REPL (default): a terminal interface for exploring MLIR pass
+    pipelines with colored diffs and history navigation.
+"""
+
+import click
+
+from mlir_opt_repl.mcp import mcp_main
+from mlir_opt_repl.repl import interactive_main
+
+
+ at click.group(invoke_without_command=True)
+ at click.pass_context
+def cli(ctx):
+    """mlir-opt-repl: Interactive MLIR pass pipeline explorer.
+
+    An MCP server and terminal REPL for running mlir-opt passes
+    incrementally, inspecting intermediate IR, rewinding to try
+    different lowering paths, and diffing what each pass changed.
+
+    By default (no subcommand), starts the interactive REPL.
+    Use 'mlir-opt-repl mcp' to start the MCP server for Claude Code.
+    """
+    if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
+        ctx.invoke(repl)
+
+
+ at cli.command()
+def mcp():
+    """Start the MCP (Model Context Protocol) server over stdio.
+
+    This is the mode used by Claude Code. The server accepts JSON-RPC
+    messages on stdin and responds on stdout, exposing tools for running
+    MLIR through pass pipelines, chaining passes, rewinding, and diffing.
+
+    To use with Claude Code, add to .claude/settings.local.json:
+
+    \b
+      {
+        "mcpServers": {
+          "mlir-opt-repl": {
+            "command": "mlir-opt-repl",
+            "args": ["mcp"],
+            "env": {"MLIR_OPT": "/path/to/mlir-opt"}
+          }
+        }
+      }
+    """
+    mcp_main()
+
+
+ at cli.command()
+def repl():
+    """Start an interactive terminal REPL.
+
+    Provides a command-line interface for loading MLIR, applying passes
+    step by step, viewing colored diffs (unified or side-by-side),
+    rewinding to try different lowering paths, and browsing history.
+
+    \b
+    Commands:
+      load <file.mlir>    Load MLIR from a file
+      load -              Load MLIR from stdin (blank line to finish)
+      run <passes...>     Apply passes to current IR
+      ir                  Show current IR
+      history             Show pass application history
+      diff [a b]          Unified diff between steps
+      sbs [a b]           Side-by-side diff between steps
+      rewind [N]          Undo last N steps (default 1)
+      reset               Clear all state
+      passes [filter]     List available passes
+      quit                Exit
+
+    \b
+    Example:
+      $ MLIR_OPT=/path/to/mlir-opt mlir-opt-repl
+      mlir-opt-repl> load test.mlir
+      mlir-opt-repl> run canonicalize
+      mlir-opt-repl> run convert-arith-to-llvm
+      mlir-opt-repl> diff
+    """
+    interactive_main()
+
+
+def main():
+    cli()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/diff.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/diff.py
new file mode 100644
index 0000000000000..5af5ddf3dfbc4
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/diff.py
@@ -0,0 +1,91 @@
+import difflib
+import shutil
+
+from mlir_opt_repl.render import BOLD, CYAN, DIM, GREEN, RED, RESET
+
+
+def render_side_by_side(
+    left_lines, right_lines, left_title, right_title, width=None, pretty=True
+):
+    if width is None:
+        width = shutil.get_terminal_size((120, 24)).columns
+    col_width = (width - 3) // 2
+
+    def truncate(s, w):
+        if len(s) <= w:
+            return s + " " * (w - len(s))
+        return s[: w - 1] + "…"
+
+    if pretty:
+        r, g, c, d, b, rst = RED, GREEN, CYAN, DIM, BOLD, RESET
+    else:
+        r, g, c, d, b, rst = "", "", "", "", "", ""
+
+    lines = []
+    header = (
+        f"{b}{c}{truncate(left_title, col_width)}{rst}"
+        f" {d}│{rst} "
+        f"{b}{c}{truncate(right_title, col_width)}{rst}"
+    )
+    lines.append(header)
+    sep_char = "─" if pretty else "-"
+    mid_char = "┼" if pretty else "+"
+    div_char = "│" if pretty else "|"
+    lines.append(d + sep_char * col_width + mid_char + sep_char * (col_width + 2) + rst)
+
+    opcodes = difflib.SequenceMatcher(None, left_lines, right_lines).get_opcodes()
+    for tag, i1, i2, j1, j2 in opcodes:
+        if tag == "equal":
+            for i, j in zip(range(i1, i2), range(j1, j2)):
+                l = truncate(left_lines[i], col_width)
+                rv = truncate(right_lines[j], col_width)
+                lines.append(f"{d}{l}{rst} {d}{div_char}{rst} {d}{rv}{rst}")
+        elif tag == "replace":
+            max_len = max(i2 - i1, j2 - j1)
+            for k in range(max_len):
+                if k < i2 - i1:
+                    l = f"{r}{truncate(left_lines[i1 + k], col_width)}{rst}"
+                else:
+                    l = " " * col_width
+                if k < j2 - j1:
+                    rv = f"{g}{truncate(right_lines[j1 + k], col_width)}{rst}"
+                else:
+                    rv = " " * col_width
+                lines.append(f"{l} {d}{div_char}{rst} {rv}")
+        elif tag == "delete":
+            for i in range(i1, i2):
+                l = f"{r}{truncate(left_lines[i], col_width)}{rst}"
+                lines.append(f"{l} {d}{div_char}{rst}")
+        elif tag == "insert":
+            for j in range(j1, j2):
+                rv = f"{g}{truncate(right_lines[j], col_width)}{rst}"
+                lines.append(f"{' ' * col_width} {d}{div_char}{rst} {rv}")
+    return "\n".join(lines)
+
+
+def render_unified_diff(prev_lines, curr_lines, prev_title, curr_title, pretty=True):
+    diff = list(
+        difflib.unified_diff(
+            prev_lines,
+            curr_lines,
+            fromfile=prev_title,
+            tofile=curr_title,
+            lineterm="",
+        )
+    )
+    if not pretty:
+        return "\n".join(diff)
+
+    colored = []
+    for line in diff:
+        if line.startswith("---") or line.startswith("+++"):
+            colored.append(f"{BOLD}{line}{RESET}")
+        elif line.startswith("@@"):
+            colored.append(f"{CYAN}{line}{RESET}")
+        elif line.startswith("-"):
+            colored.append(f"{RED}{line}{RESET}")
+        elif line.startswith("+"):
+            colored.append(f"{GREEN}{line}{RESET}")
+        else:
+            colored.append(f"{DIM}{line}{RESET}")
+    return "\n".join(colored)
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/engine.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/engine.py
new file mode 100644
index 0000000000000..72f2b1a06f353
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/engine.py
@@ -0,0 +1,211 @@
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+from mlir_opt_repl.diff import render_side_by_side, render_unified_diff
+
+MLIR_OPT = os.environ.get("MLIR_OPT", "mlir-opt")
+
+
+def check_mlir_opt():
+    if not shutil.which(MLIR_OPT):
+        print(
+            f"error: mlir-opt not found at '{MLIR_OPT}'. "
+            "Set MLIR_OPT to the path of your mlir-opt binary.",
+            file=sys.stderr,
+        )
+        sys.exit(1)
+
+
+current_ir = None
+ir_history = []
+
+
+def run_mlir_opt(ir_text, args):
+    with tempfile.NamedTemporaryFile(mode="w", suffix=".mlir", delete=False) as f:
+        f.write(ir_text)
+        f.flush()
+        tmp_path = f.name
+
+    try:
+        cmd = [MLIR_OPT] + args + [tmp_path]
+        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+        if result.returncode != 0:
+            return None, result.stderr.strip()
+        return result.stdout.strip(), None
+    except subprocess.TimeoutExpired:
+        return None, "mlir-opt timed out after 30 seconds"
+    except FileNotFoundError:
+        return None, f"mlir-opt not found at: {MLIR_OPT}"
+    finally:
+        os.unlink(tmp_path)
+
+
+def list_passes():
+    try:
+        result = subprocess.run(
+            [MLIR_OPT, "--help"], capture_output=True, text=True, timeout=10
+        )
+        passes = []
+        for line in result.stdout.splitlines():
+            stripped = line.strip()
+            if (
+                stripped.startswith("--convert-")
+                or stripped.startswith("--canonicalize")
+                or stripped.startswith("--cse")
+                or stripped.startswith("--inline")
+                or stripped.startswith("--mem2reg")
+                or stripped.startswith("--symbol-")
+            ):
+                parts = stripped.split(" - ", 1)
+                name = parts[0].strip().lstrip("-")
+                desc = parts[1].strip() if len(parts) > 1 else ""
+                passes.append({"name": name, "description": desc})
+        return passes
+    except Exception as e:
+        return [{"error": str(e)}]
+
+
+def handle_tool_call(name, arguments):
+    global current_ir, ir_history
+
+    if name == "run_pipeline":
+        mlir = arguments["mlir"]
+        passes = ["--" + p.lstrip("-") for p in arguments["passes"]]
+        extra = ["--" + a.lstrip("-") for a in arguments.get("extra_args", [])]
+        output, err = run_mlir_opt(mlir, passes + extra)
+        if err:
+            return {
+                "content": [{"type": "text", "text": f"Error:\n{err}"}],
+                "isError": True,
+            }
+        ir_history = [("initial", mlir)]
+        current_ir = output
+        ir_history.append((" ".join(passes), output))
+        return {"content": [{"type": "text", "text": output}]}
+
+    elif name == "chain_pipeline":
+        if current_ir is None:
+            return {
+                "content": [
+                    {
+                        "type": "text",
+                        "text": "Error: no current IR state. Use run_pipeline first.",
+                    }
+                ],
+                "isError": True,
+            }
+        passes = ["--" + p.lstrip("-") for p in arguments["passes"]]
+        extra = ["--" + a.lstrip("-") for a in arguments.get("extra_args", [])]
+        output, err = run_mlir_opt(current_ir, passes + extra)
+        if err:
+            return {
+                "content": [
+                    {"type": "text", "text": f"Error:\n{err}\n\nCurrent IR unchanged."}
+                ],
+                "isError": True,
+            }
+        current_ir = output
+        ir_history.append((" ".join(passes), output))
+        return {"content": [{"type": "text", "text": output}]}
+
+    elif name == "get_current_ir":
+        if current_ir is None:
+            return {"content": [{"type": "text", "text": "(no IR state set)"}]}
+        return {"content": [{"type": "text", "text": current_ir}]}
+
+    elif name == "reset":
+        current_ir = None
+        ir_history = []
+        return {"content": [{"type": "text", "text": "IR state cleared."}]}
+
+    elif name == "rewind":
+        if not ir_history:
+            return {
+                "content": [{"type": "text", "text": "Error: no history to rewind."}],
+                "isError": True,
+            }
+        steps = arguments.get("steps", 1)
+        if steps >= len(ir_history):
+            desc, ir = ir_history[0]
+            ir_history = [ir_history[0]]
+            current_ir = ir
+            return {
+                "content": [
+                    {
+                        "type": "text",
+                        "text": f"Rewound to beginning ({desc}).\n\n{ir}",
+                    }
+                ]
+            }
+        ir_history = ir_history[:-steps]
+        desc, ir = ir_history[-1]
+        current_ir = ir
+        return {
+            "content": [
+                {
+                    "type": "text",
+                    "text": f"Rewound {steps} step(s). Now at: {desc}\n\n{ir}",
+                }
+            ]
+        }
+
+    elif name == "history":
+        if not ir_history:
+            return {"content": [{"type": "text", "text": "(no history)"}]}
+        show_ir = arguments.get("show_ir", False)
+        fmt = arguments.get("format")
+        pretty = arguments.get("pretty", False)
+        width = arguments.get("width")
+        lines = []
+        for i, (desc, ir) in enumerate(ir_history):
+            marker = " <-- current" if i == len(ir_history) - 1 else ""
+            lines.append(f"[{i}] {desc}{marker}")
+            if fmt == "side_by_side" and i > 0:
+                prev_ir = ir_history[i - 1][1]
+                sbs = render_side_by_side(
+                    prev_ir.splitlines(),
+                    ir.splitlines(),
+                    ir_history[i - 1][0],
+                    desc,
+                    width=width,
+                    pretty=pretty,
+                )
+                lines.append(sbs)
+                lines.append("")
+            elif fmt == "unified" and i > 0:
+                prev_ir = ir_history[i - 1][1]
+                lines.append(
+                    render_unified_diff(
+                        prev_ir.splitlines(),
+                        ir.splitlines(),
+                        ir_history[i - 1][0],
+                        desc,
+                        pretty=pretty,
+                    )
+                )
+                lines.append("")
+            elif show_ir:
+                lines.append(ir)
+                lines.append("")
+        return {"content": [{"type": "text", "text": "\n".join(lines)}]}
+
+    elif name == "list_passes":
+        filt = arguments.get("filter", "")
+        passes = list_passes()
+        if filt:
+            passes = [
+                p
+                for p in passes
+                if filt.lower() in p.get("name", "").lower()
+                or filt.lower() in p.get("description", "").lower()
+            ]
+        text = "\n".join(f"--{p['name']}: {p['description']}" for p in passes)
+        return {"content": [{"type": "text", "text": text or "(no passes matched)"}]}
+
+    return {
+        "content": [{"type": "text", "text": f"Unknown tool: {name}"}],
+        "isError": True,
+    }
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/mcp.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/mcp.py
new file mode 100644
index 0000000000000..a25e2d0247b54
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/mcp.py
@@ -0,0 +1,201 @@
+import json
+import sys
+
+from mlir_opt_repl.engine import check_mlir_opt, handle_tool_call
+
+TOOLS = [
+    {
+        "name": "run_pipeline",
+        "description": "Run MLIR source through mlir-opt with the given passes. Sets the result as the current IR state for subsequent chain_pipeline calls.",
+        "inputSchema": {
+            "type": "object",
+            "properties": {
+                "mlir": {
+                    "type": "string",
+                    "description": "MLIR source text to process",
+                },
+                "passes": {
+                    "type": "array",
+                    "items": {"type": "string"},
+                    "description": "List of pass flags (e.g. ['--convert-arith-to-llvm', '--convert-func-to-llvm']). Can also be a single pass-pipeline string.",
+                },
+                "extra_args": {
+                    "type": "array",
+                    "items": {"type": "string"},
+                    "description": "Additional mlir-opt arguments (e.g. ['--allow-unregistered-dialect'])",
+                    "default": [],
+                },
+            },
+            "required": ["mlir", "passes"],
+        },
+    },
+    {
+        "name": "chain_pipeline",
+        "description": "Feed the current IR state (from a previous run_pipeline or chain_pipeline) through additional passes. Use this to incrementally lower IR step by step.",
+        "inputSchema": {
+            "type": "object",
+            "properties": {
+                "passes": {
+                    "type": "array",
+                    "items": {"type": "string"},
+                    "description": "List of pass flags to apply to the current IR state",
+                },
+                "extra_args": {
+                    "type": "array",
+                    "items": {"type": "string"},
+                    "description": "Additional mlir-opt arguments",
+                    "default": [],
+                },
+            },
+            "required": ["passes"],
+        },
+    },
+    {
+        "name": "get_current_ir",
+        "description": "Return the current IR state without running any passes.",
+        "inputSchema": {
+            "type": "object",
+            "properties": {},
+        },
+    },
+    {
+        "name": "reset",
+        "description": "Clear the current IR state.",
+        "inputSchema": {
+            "type": "object",
+            "properties": {},
+        },
+    },
+    {
+        "name": "list_passes",
+        "description": "List available mlir-opt passes (conversion passes, canonicalize, cse, etc.)",
+        "inputSchema": {
+            "type": "object",
+            "properties": {
+                "filter": {
+                    "type": "string",
+                    "description": "Optional substring to filter pass names",
+                    "default": "",
+                }
+            },
+        },
+    },
+    {
+        "name": "rewind",
+        "description": "Rewind the IR state by N steps (default 1). Undoes the last N pass applications.",
+        "inputSchema": {
+            "type": "object",
+            "properties": {
+                "steps": {
+                    "type": "integer",
+                    "description": "Number of steps to rewind (default 1)",
+                    "default": 1,
+                    "minimum": 1,
+                }
+            },
+        },
+    },
+    {
+        "name": "history",
+        "description": "Show the history of pass applications and their IR states.",
+        "inputSchema": {
+            "type": "object",
+            "properties": {
+                "show_ir": {
+                    "type": "boolean",
+                    "description": "Include full IR text in each history entry",
+                    "default": False,
+                },
+                "format": {
+                    "type": "string",
+                    "enum": ["unified", "side_by_side"],
+                    "description": "Diff format: 'unified' for standard unified diff, 'side_by_side' for two-column comparison",
+                },
+                "pretty": {
+                    "type": "boolean",
+                    "description": "Use ANSI colors and box-drawing characters for terminal display",
+                    "default": False,
+                },
+                "width": {
+                    "type": "integer",
+                    "description": "Terminal width for side-by-side mode (auto-detected if omitted)",
+                },
+            },
+        },
+    },
+]
+
+
+def send(msg):
+    text = json.dumps(msg)
+    sys.stdout.write(f"Content-Length: {len(text.encode())}\r\n\r\n{text}")
+    sys.stdout.flush()
+
+
+def read_message():
+    headers = {}
+    while True:
+        line = sys.stdin.readline()
+        if not line:
+            return None
+        line = line.strip()
+        if line == "":
+            break
+        if ":" in line:
+            key, val = line.split(":", 1)
+            headers[key.strip()] = val.strip()
+
+    length = int(headers.get("Content-Length", 0))
+    if length == 0:
+        return None
+    body = sys.stdin.read(length)
+    return json.loads(body)
+
+
+def dispatch(msg):
+    method = msg.get("method", "")
+    msg_id = msg.get("id")
+
+    if method == "initialize":
+        send(
+            {
+                "jsonrpc": "2.0",
+                "id": msg_id,
+                "result": {
+                    "protocolVersion": "2024-11-05",
+                    "capabilities": {"tools": {"listChanged": False}},
+                    "serverInfo": {"name": "mlir-opt-repl", "version": "0.1.0"},
+                },
+            }
+        )
+    elif method == "notifications/initialized":
+        pass
+    elif method == "tools/list":
+        send(
+            {
+                "jsonrpc": "2.0",
+                "id": msg_id,
+                "result": {"tools": TOOLS},
+            }
+        )
+    elif method == "tools/call":
+        params = msg.get("params", {})
+        result = handle_tool_call(params["name"], params.get("arguments", {}))
+        send({"jsonrpc": "2.0", "id": msg_id, "result": result})
+    elif msg_id is not None:
+        send(
+            {
+                "jsonrpc": "2.0",
+                "id": msg_id,
+                "error": {"code": -32601, "message": f"Method not found: {method}"},
+            }
+        )
+
+
+def mcp_main():
+    check_mlir_opt()
+    while True:
+        msg = read_message()
+        if msg is None:
+            break
+        dispatch(msg)
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/render.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/render.py
new file mode 100644
index 0000000000000..2c40e8be2ad03
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/render.py
@@ -0,0 +1,7 @@
+# ANSI escape codes
+RED = "\033[31m"
+GREEN = "\033[32m"
+CYAN = "\033[36m"
+DIM = "\033[2m"
+BOLD = "\033[1m"
+RESET = "\033[0m"
diff --git a/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/repl.py b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/repl.py
new file mode 100644
index 0000000000000..68a94e7356f58
--- /dev/null
+++ b/mlir/tools/mlir-opt-repl/src/mlir_opt_repl/repl.py
@@ -0,0 +1,206 @@
+from textwrap import dedent
+
+from mlir_opt_repl import engine
+from mlir_opt_repl.diff import render_side_by_side, render_unified_diff
+from mlir_opt_repl.render import BOLD, CYAN, DIM, GREEN, RED, RESET
+
+
+def interactive_main():
+    import readline  # noqa: F401 — enables line editing in input()
+
+    engine.check_mlir_opt()
+
+    print(f"{BOLD}mlir-opt-repl{RESET} (using {engine.MLIR_OPT})")
+    print(
+        "Commands: load <file>, run <passes...>, rewind [N], history, diff, sbs, ir, reset, quit"
+    )
+    print()
+
+    while True:
+        try:
+            line = input(f"{CYAN}mlir-opt-repl>{RESET} ")
+        except (EOFError, KeyboardInterrupt):
+            print()
+            break
+
+        line = line.strip()
+        if not line:
+            continue
+
+        parts = line.split()
+        cmd = parts[0]
+
+        if cmd in ("quit", "exit", "q"):
+            break
+
+        elif cmd == "load":
+            if len(parts) < 2:
+                print(
+                    f"{RED}Usage: load <file.mlir> OR load - (read from stdin until blank line){RESET}"
+                )
+                continue
+            if parts[1] == "-":
+                print(f"{DIM}Enter MLIR (blank line to finish):{RESET}")
+                mlir_lines = []
+                while True:
+                    try:
+                        l = input()
+                    except EOFError:
+                        break
+                    if l == "":
+                        break
+                    mlir_lines.append(l)
+                mlir_text = "\n".join(mlir_lines)
+            else:
+                path = " ".join(parts[1:])
+                try:
+                    with open(path) as f:
+                        mlir_text = f.read()
+                except FileNotFoundError:
+                    print(f"{RED}File not found: {path}{RESET}")
+                    continue
+            # Validate the IR by running mlir-opt with no passes
+            _, err = engine.run_mlir_opt(mlir_text, [])
+            if err:
+                print(f"{RED}Invalid MLIR:{RESET}")
+                print(f"{RED}{err}{RESET}")
+                continue
+            engine.current_ir = mlir_text
+            engine.ir_history = [("initial", mlir_text)]
+            print(
+                f"{GREEN}Loaded ({len(mlir_text)} bytes). Use 'run <passes>' to apply passes.{RESET}"
+            )
+
+        elif cmd == "run":
+            if not parts[1:]:
+                print(f"{RED}Usage: run <pass1> [pass2] ...{RESET}")
+                continue
+            if engine.current_ir is None:
+                print(f"{RED}No IR loaded. Use 'load <file>' first.{RESET}")
+                continue
+            passes = ["--" + p.lstrip("-") for p in parts[1:]]
+            output, err = engine.run_mlir_opt(engine.current_ir, passes)
+            if err:
+                print(f"{RED}{err}{RESET}")
+                print(f"{DIM}Current IR unchanged.{RESET}")
+            else:
+                engine.current_ir = output
+                engine.ir_history.append((" ".join(passes), output))
+                print(output)
+
+        elif cmd == "rewind":
+            if not engine.ir_history:
+                print(f"{RED}No history to rewind.{RESET}")
+                continue
+            steps = int(parts[1]) if len(parts) > 1 else 1
+            if steps >= len(engine.ir_history):
+                desc, ir = engine.ir_history[0]
+                engine.ir_history = [engine.ir_history[0]]
+                engine.current_ir = ir
+                print(f"{GREEN}Rewound to beginning ({desc}).{RESET}")
+            else:
+                engine.ir_history = engine.ir_history[:-steps]
+                desc, ir = engine.ir_history[-1]
+                engine.current_ir = ir
+                print(f"{GREEN}Rewound {steps} step(s). Now at: {desc}{RESET}")
+            print()
+            print(engine.current_ir)
+
+        elif cmd == "history":
+            if not engine.ir_history:
+                print(f"{DIM}(no history){RESET}")
+                continue
+            for i, (desc, _) in enumerate(engine.ir_history):
+                marker = (
+                    f" {GREEN}<-- current{RESET}"
+                    if i == len(engine.ir_history) - 1
+                    else ""
+                )
+                print(f"  {BOLD}[{i}]{RESET} {desc}{marker}")
+
+        elif cmd == "diff":
+            if len(engine.ir_history) < 2:
+                print(f"{DIM}(need at least 2 history entries){RESET}")
+                continue
+            if len(parts) == 3:
+                a, b = int(parts[1]), int(parts[2])
+            else:
+                a, b = len(engine.ir_history) - 2, len(engine.ir_history) - 1
+            if a < 0 or b >= len(engine.ir_history):
+                print(f"{RED}Invalid indices (0..{len(engine.ir_history)-1}){RESET}")
+                continue
+            print(
+                render_unified_diff(
+                    engine.ir_history[a][1].splitlines(),
+                    engine.ir_history[b][1].splitlines(),
+                    engine.ir_history[a][0],
+                    engine.ir_history[b][0],
+                    pretty=True,
+                )
+            )
+
+        elif cmd == "sbs":
+            if len(engine.ir_history) < 2:
+                print(f"{DIM}(need at least 2 history entries){RESET}")
+                continue
+            if len(parts) == 3:
+                a, b = int(parts[1]), int(parts[2])
+            else:
+                a, b = len(engine.ir_history) - 2, len(engine.ir_history) - 1
+            if a < 0 or b >= len(engine.ir_history):
+                print(f"{RED}Invalid indices (0..{len(engine.ir_history)-1}){RESET}")
+                continue
+            print(
+                render_side_by_side(
+                    engine.ir_history[a][1].splitlines(),
+                    engine.ir_history[b][1].splitlines(),
+                    engine.ir_history[a][0],
+                    engine.ir_history[b][0],
+                    pretty=True,
+                )
+            )
+
+        elif cmd == "ir":
+            if engine.current_ir is None:
+                print(f"{DIM}(no IR state set){RESET}")
+            else:
+                print(engine.current_ir)
+
+        elif cmd == "reset":
+            engine.current_ir = None
+            engine.ir_history = []
+            print(f"{GREEN}IR state cleared.{RESET}")
+
+        elif cmd == "passes":
+            filt = parts[1] if len(parts) > 1 else ""
+            all_passes = engine.list_passes()
+            if filt:
+                all_passes = [
+                    p
+                    for p in all_passes
+                    if filt.lower() in p.get("name", "").lower()
+                    or filt.lower() in p.get("description", "").lower()
+                ]
+            for p in all_passes:
+                print(f"  {BOLD}--{p['name']}{RESET}: {DIM}{p['description']}{RESET}")
+            if not all_passes:
+                print(f"{DIM}(no passes matched){RESET}")
+
+        elif cmd == "help":
+            print(dedent(f"""\
+                {BOLD}Commands:{RESET}
+                  {CYAN}load <file.mlir>{RESET}    Load MLIR from a file
+                  {CYAN}load -{RESET}              Load MLIR from stdin (blank line to finish)
+                  {CYAN}run <passes...>{RESET}     Apply passes to current IR
+                  {CYAN}ir{RESET}                  Show current IR
+                  {CYAN}history{RESET}             Show pass application history
+                  {CYAN}diff [a b]{RESET}          Unified diff (last step, or between indices a and b)
+                  {CYAN}sbs [a b]{RESET}           Side-by-side diff (last step, or between indices a and b)
+                  {CYAN}rewind [N]{RESET}          Undo last N steps (default 1)
+                  {CYAN}reset{RESET}               Clear all state
+                  {CYAN}passes [filter]{RESET}     List available passes
+                  {CYAN}quit{RESET}                Exit
+            """))
+
+        else:
+            print(f"{RED}Unknown command: {cmd}. Type 'help' for usage.{RESET}")



More information about the llvm-commits mailing list