From 5befeb9c47882e001942074b9bf6556530f0c666 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 1 Oct 2026 23:20:57 -0300 Subject: [PATCH] feat(mcp): list the server in the official MCP Registry Add server.json and the mcp-name marker the registry's PyPI ownership check reads from the README. A registry client starts a PyPI server as `uvx --from "PyMemoryEditor[mcp]==X" pymemoryeditor mcp`, by the project name, so `pymemoryeditor mcp [flags]` now runs the MCP server (without Qt), the same as `pymemoryeditor-mcp`. Tests keep server.json, the README marker and the package version in step. --- PyMemoryEditor/app/application.py | 13 +++++ README.md | 2 + docs/mcp.md | 3 ++ pyproject.toml | 1 + server.json | 38 +++++++++++++ tests/mcp_server/test_registry.py | 88 +++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+) create mode 100644 server.json create mode 100644 tests/mcp_server/test_registry.py diff --git a/PyMemoryEditor/app/application.py b/PyMemoryEditor/app/application.py index bcc65ba..a67a19e 100644 --- a/PyMemoryEditor/app/application.py +++ b/PyMemoryEditor/app/application.py @@ -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) diff --git a/README.md b/README.md index b20fd69..1d32b52 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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! --- diff --git a/docs/mcp.md b/docs/mcp.md index b8d025f..a25194e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 9ac1b90..a043d7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"`` diff --git a/server.json b/server.json new file mode 100644 index 0000000..75fa304 --- /dev/null +++ b/server.json @@ -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" + } + } + ] +} diff --git a/tests/mcp_server/test_registry.py b/tests/mcp_server/test_registry.py new file mode 100644 index 0000000..7867443 --- /dev/null +++ b/tests/mcp_server/test_registry.py @@ -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 +``, 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"]]