Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions PyMemoryEditor/app/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,20 @@ def main_cli(argv=None):

Kept separate from :func:`main` so that process-wide change only happens
when the app *is* the process, never when it is embedded in someone else's.

``pymemoryeditor mcp [flags]`` runs the MCP server instead, the same as
``pymemoryeditor-mcp [flags]``. MCP registries launch a PyPI package by its
project name (``uvx --from "PyMemoryEditor[mcp]" pymemoryeditor mcp``), so
this is the only command they can reach. It never touches Qt, and the
server keeps its own signal handling.
"""
if argv is None:
argv = sys.argv
if len(argv) > 1 and argv[1].strip() == "mcp":
from PyMemoryEditor.mcp.server import main as mcp_main

return mcp_main(argv[2:])

with _scoped_signal_handler(signal.SIGINT, signal.SIG_DFL):
return main(argv)

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# PyMemoryEditor

<!-- mcp-name: io.github.JeanExtreme002/PyMemoryEditor -->

A pure-Python library (built on [ctypes](https://docs.python.org/3/library/ctypes.html)) that lets you **inspect, modify and search the memory of any running process in a few lines of Python** — Cheat Engine workflows on Windows, Linux and macOS!

---
Expand Down
3 changes: 3 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ pymemoryeditor-mcp --allow-process game.exe # never asks for game.exe
pymemoryeditor-mcp --allow-any-process # never asks at all (scripts/CI)
```

`pymemoryeditor mcp` is the same command, flags included: it is how clients
that install from the MCP Registry start the server.

## Flags

<table>
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ exclude = [
"/.coveragerc-lib",
"/.coveragerc-mcp",
"/scripts",
"/server.json",
]

# hatchling>=1.27 is required for PEP 639 metadata: the SPDX ``license = "MIT"``
Expand Down
38 changes: 38 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.JeanExtreme002/PyMemoryEditor",
"title": "PyMemoryEditor",
"description": "Read, write and scan process memory, Cheat Engine style, on Windows, Linux and macOS.",
"repository": {
"url": "https://github.com/JeanExtreme002/PyMemoryEditor",
"source": "github"
},
"websiteUrl": "https://pymemoryeditor.readthedocs.io/en/latest/mcp.html",
"version": "3.0.1",
"packages": [
{
"registryType": "pypi",
"registryBaseUrl": "https://pypi.org",
"identifier": "pymemoryeditor",
"version": "3.0.1",
"runtimeHint": "uvx",
"runtimeArguments": [
{
"type": "named",
"name": "--from",
"value": "PyMemoryEditor[mcp]==3.0.1",
"description": "Install the MCP extra, pinned to this release."
}
],
"packageArguments": [
{
"type": "positional",
"value": "mcp"
}
],
"transport": {
"type": "stdio"
}
}
]
}
88 changes: 88 additions & 0 deletions tests/mcp_server/test_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-

"""
The MCP Registry listing (server.json) and the command it makes clients run.

A registry client starts a PyPI server as ``uvx <runtime args> <identifier>
<package args>``, with the identifier being the PyPI project. So the listing
only works while four things agree: the server name in server.json and the
``mcp-name`` marker in the README that PyPI shows (the registry's ownership
check), the versions in server.json and the package, and the console script
the identifier names, which must start the server when given ``mcp``.
"""

import json
import re
import sys
from pathlib import Path

import pytest

import PyMemoryEditor

ROOT = Path(__file__).resolve().parents[2]


@pytest.fixture(scope="module")
def server():
return json.loads((ROOT / "server.json").read_text(encoding="utf-8"))


@pytest.fixture(scope="module")
def package(server):
(package,) = server["packages"]
return package


def test_readme_carries_the_registry_name(server):
readme = (ROOT / "README.md").read_text(encoding="utf-8")

assert re.findall(r"mcp-name: (\S+) -->", readme) == [server["name"]]


def test_versions_follow_the_package(server, package):
version = PyMemoryEditor.__version__

assert server["version"] == package["version"] == version
assert {"type": "named", "name": "--from"}.items() <= package["runtimeArguments"][0].items()
assert package["runtimeArguments"][0]["value"] == "PyMemoryEditor[mcp]==" + version


def test_the_command_a_client_builds_starts_the_server(package):
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
scripts = re.search(r"\[project\.scripts\](.*?)\n\[", pyproject, re.S).group(1)

# uvx looks the executable up by its exact name, and the console scripts
# are lowercase: "PyMemoryEditor" would not be found on Linux.
assert re.search(r'^{} = "PyMemoryEditor\.app\.application:main_cli"$'.format(package["identifier"]), scripts, re.M)
assert package["runtimeHint"] == "uvx"
assert package["packageArguments"] == [{"type": "positional", "value": "mcp"}]
assert package["transport"] == {"type": "stdio"}


def test_description_fits_the_registry_limit(server):
# The registry rejects a description over 100 characters at publish time.
assert len(server["description"]) <= 100


def test_mcp_subcommand_runs_the_server_without_qt(monkeypatch):
from PyMemoryEditor.app import application
from PyMemoryEditor.mcp import server as mcp_server

received = []
monkeypatch.setattr(mcp_server, "main", lambda argv: received.append(list(argv)) or 0)
monkeypatch.setattr(application, "main", lambda argv: pytest.fail("the Qt app started"))
monkeypatch.setitem(sys.modules, "PySide6", None) # any Qt import would raise

assert application.main_cli(["pymemoryeditor", "mcp", "--read-only", "--allow-process", "game.exe"]) == 0
assert received == [["--read-only", "--allow-process", "game.exe"]]


def test_other_arguments_still_reach_the_app(monkeypatch):
from PyMemoryEditor.app import application

received = []
monkeypatch.setattr(application, "main", lambda argv: received.append(argv))

application.main_cli(["pymemoryeditor", "--version"])
assert received == [["pymemoryeditor", "--version"]]
Loading