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
23 changes: 22 additions & 1 deletion PyMemoryEditor/mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,28 @@
#: value (``int`` ``0``, ``100``) legitimately matches millions of addresses;
#: keeping them all would blow out the server's memory for a result set no
#: refine loop can use anyway. At the cap the scan stops early and says so.
DEFAULT_MAX_SCAN_RESULTS = 50_000
#:
#: 100 000 costs 4.2 MB per result set against 2.1 MB, and 21 ms to sort
#: against 10 ms. The figure that matters is not per set, though: a session
#: keeps ``MAX_SCANS_PER_SESSION`` (20) of them and a server keeps
#: ``MAX_OPEN_SESSIONS`` (8) sessions, so the ceiling on retained addresses
#: goes from ~336 MB to ~672 MB. Reaching it needs 160 capped result sets,
#: which a long refine chain across several targets can do.
#:
#: What it buys: a scan whose true hit count falls between the two ceilings
#: stops being flagged ``partial``, and refining a truncated set can converge
#: on an address that was never in it. That case is real but narrow — above
#: the new ceiling nothing changes, and a common value like ``int 0`` matches
#: millions and is out of reach at any sane cap.
#:
#: What it costs in time depends on the value's density, and an earlier
#: version of this comment got that wrong. Measured on ``int 0``, reaching
#: either ceiling took 0.02s against 0.03s — but that is the dense case, where
#: hits arrive faster than the clock can spend. For a sparser value the scan
#: has to walk further to collect twice as many hits, so the time roughly
#: doubles up to the 30-second budget. "Wall clock is unchanged" was true of
#: one measurement, not of the change.
DEFAULT_MAX_SCAN_RESULTS = 100_000

#: Wall-clock budget for one scan, in seconds. A full address-space scan of a
#: large process takes minutes — long past the point where an MCP client gives
Expand Down
16 changes: 16 additions & 0 deletions PyMemoryEditor/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,22 @@ async def open_process(pid: int = 0, name: str = "", *, ctx: Context) -> Any:
if not _can_elicit(ctx):
raise SdkToolError(decision.reason)

# Before the prompt, not after. The cap lives in SessionStore.open,
# which runs once the handle is already open -- so without this the
# user was asked to approve an attach, approved it, and then got told
# the server was full. An approval is the most expensive step here and
# the one thing this server must not spend carelessly.
#
# The store renders the message, rather than this having its own: the
# first version here named neither the open sessions nor their scan
# counts, so the path a model actually hits was the less useful one.
refusal = toolset.store.capacity_refusal(found_pid)
if refusal is not None:
raise SdkToolError(
'Not asking to attach to "%s" (pid %d): %s'
% (found_name or "unknown", found_pid, refusal)
)

try:
answer = await ctx.elicit(
message=(
Expand Down
169 changes: 163 additions & 6 deletions PyMemoryEditor/mcp/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,19 @@
from typing import Dict, List, Optional, Sequence, Tuple

from ..process.abstract import AbstractProcess
from ..process.util import pid_exists
from ..process.region import MemoryRegion, MemoryRegionSnapshot


#: Result sets retained per session before the oldest is evicted.
MAX_SCANS_PER_SESSION = 20

#: Processes one server keeps open at once. Refuses rather than evicting,
#: unlike the scan cap above: a session owns an OS handle, and closing one out
#: from under the model is not something it can detect. Bounds handle
#: exhaustion, since nothing obliges a model to call ``close_process``.
MAX_OPEN_SESSIONS = 8


class SessionError(Exception):
"""An unknown or expired session/scan id.
Expand Down Expand Up @@ -162,6 +169,22 @@ def scan_ids(self) -> Tuple[str, ...]:
return tuple(self._scan_order)


def looks_alive(pid: int) -> bool:
"""``pid_exists``, but a pid the OS will not even accept reads as dead.

The reaper runs inside ``open``, so anything raised here escapes as
something other than a ``SessionError`` and the model is told only
"Error executing tool". ``pid_exists(2**31)`` raises ``OverflowError``
(the value does not fit the platform's pid type), which the argument
fuzzer found by generating exactly that. A number the OS refuses cannot
name a running process, so treating it as dead is both safe and correct.
"""
try:
return pid_exists(pid)
except (OverflowError, ValueError, OSError):
return False


class SessionStore:
"""The server's process handles and their scan results.

Expand All @@ -178,14 +201,146 @@ def __init__(self) -> None:
self._lock = threading.Lock()

def open(self, process: AbstractProcess, pid: int, name: str) -> Session:
"""Register an already-opened process and return its session."""
"""Register an already-opened process and return its session.

:raises SessionError: if :data:`MAX_OPEN_SESSIONS` are already open.
The caller owns the handle it passed in and must close it.
"""
# Loop, rather than deciding to reap from a check taken earlier under
# a different acquisition: the store can fill between the two, and then
# a slot held by an exited process would be refused without anyone
# having looked. At most one reap — a second pass finds nothing new,
# and refusing has to terminate.
reaped = False

while True:
with self._lock:
refusal = self._refusal_locked(pid)

if refusal is None:
session_id = "proc-%d" % next(self._session_ids)
session = Session(
session_id=session_id, process=process,
pid=pid, name=name,
)
self._sessions[session_id] = session
return session

if reaped:
# Raised here, not after the block: computing the refusal
# under the lock and raising it outside leaves a window
# where another thread closes a session, and this caller
# is turned away with a message that is already false.
raise SessionError(refusal)

# Outside the lock: reaping closes handles, which takes it again.
self._reap_dead()
reaped = True

def _reap_dead(self) -> List[str]:
"""Drop sessions whose target no longer exists. Caller holds no lock.

The cap refuses rather than evicting, because closing a live handle
out from under the model is something it cannot detect. A *dead*
target is the exception that proves the rule: the handle is already
useless, so dropping it takes nothing away — and without this, eight
exited processes hold every slot forever and `open_process` is refused
for the rest of the server's life, with no way for the model to find
out which sessions are the dead ones (`process_info` answers happily
from cached state).

A recycled pid reads as alive, which is inherent to asking the OS by
number.
"""
with self._lock:
dead = [
session_id
for session_id, session in self._sessions.items()
if not looks_alive(session.pid)
]

for session_id in dead:
try:
self.close(session_id)
except SessionError: # closed concurrently
pass
return dead

def capacity_refusal(self, pid: int = 0) -> Optional[str]:
"""The reason :meth:`open` would refuse right now, or ``None``.

One message, rendered once. The protocol layer asks this *before*
prompting the user — spending an approval on an attach that cannot
happen is worse than refusing outright — and :meth:`open` raises it as
the authoritative guard. Duplicating the text gave the early path a
worse message than the late one, which is the path a model actually
hits.

Reaps first, or a server full of exited processes reports as full when
:meth:`open` would have made room.
"""
with self._lock:
if len(self._sessions) < MAX_OPEN_SESSIONS:
return None

self._reap_dead()

with self._lock:
session_id = "proc-%d" % next(self._session_ids)
session = Session(
session_id=session_id, process=process, pid=pid, name=name
return self._refusal_locked(pid)

def _refusal_locked(self, pid: int) -> Optional[str]:
"""Render the refusal, or ``None``. **Caller holds** ``self._lock``.

Separate from :meth:`capacity_refusal` so :meth:`open` can check and
insert under one acquisition. When the check released the lock and the
insert took it again, the cap could be exceeded: every thread saw room,
then every thread inserted. Demonstrated with 12 concurrent opens
against a cap of 8.
"""
# Deterministic, so a refactor that checks capacity outside the lock
# fails here instead of in a timing test. That regression happened
# once: the check and the insert were split across two acquisitions,
# every racing thread saw room, and the cap was exceeded.
assert self._lock.locked(), "capacity must be judged under the lock"

if len(self._sessions) < MAX_OPEN_SESSIONS:
return None

# No "close the oldest": the oldest is usually the target the whole
# session has been refining, and close_process discards its scan
# results too. The list is given instead, so the choice is made on
# what each session holds.
existing = [
sid for sid, session in self._sessions.items()
if session.pid == pid
]
already = (
" Note that pid %d is already open as %s — reuse that id "
"rather than attaching again." % (pid, existing[0])
if existing
else ""
)
return (
"This server already has %d processes open, which is the "
"limit, and all of them are live. Close whichever you are "
"done with (close_process also discards that session's scan "
"results) and attach again. Open: %s.%s"
% (
MAX_OPEN_SESSIONS,
", ".join(
"%s (%s, pid %d, %d scan%s)"
% (sid, session.name or "?", session.pid,
len(session.scan_ids),
"" if len(session.scan_ids) == 1 else "s")
for sid, session in self._sessions.items()
),
already,
)
self._sessions[session_id] = session
return session
)

def at_capacity(self) -> bool:
"""Whether :meth:`open` would refuse right now."""
return self.capacity_refusal() is not None

def get(self, session_id: str) -> Session:
"""Look up a session, or explain how to obtain a valid id."""
Expand Down Expand Up @@ -377,6 +532,8 @@ def host_platform() -> str:


__all__ = (
"MAX_OPEN_SESSIONS",
"looks_alive",
"MAX_SCANS_PER_SESSION",
"ScanResult",
"Session",
Expand Down
36 changes: 34 additions & 2 deletions PyMemoryEditor/mcp/toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,15 @@
)
from .config import ServerConfig
from .session import (
MAX_OPEN_SESSIONS,
MAX_SCANS_PER_SESSION,
ScanResult,
Session,
SessionError,
SessionStore,
batch_regions,
host_platform,
looks_alive,
region_to_dict,
)

Expand Down Expand Up @@ -553,12 +557,23 @@ def server_info(self) -> Dict[str, Any]:
reachable, how long a scan may run, and which sessions are already
open from earlier in the conversation.
"""
# `alive` because a session outlives its target: nothing here notices
# a process exiting, `process_info` keeps answering from cached state,
# and the open-session cap means dead ones would hold slots the model
# cannot identify. The store reaps them when it needs room; this is how
# the model sees them before that.
sessions = [
{
"session_id": session.session_id,
"pid": session.pid,
"name": session.name,
"scan_ids": list(session.scan_ids),
# `looks_alive`, not `pid_exists`: the raw call raises
# OverflowError for a pid outside the platform's range, and
# server_info is the tool the instructions tell the model to
# call first — a crash here hides the limits and the policy
# too, not just this field.
"alive": looks_alive(session.pid),
}
for session in self.store.sessions
]
Expand Down Expand Up @@ -599,6 +614,8 @@ def server_info(self) -> Dict[str, Any]:
# to.
"max_address": format_address(MAX_ADDRESS),
"max_offset_magnitude": format_address(MAX_OFFSET_MAGNITUDE),
"max_open_sessions": MAX_OPEN_SESSIONS,
"max_scans_per_session": MAX_SCANS_PER_SESSION,
},
"open_sessions": sessions,
}
Expand Down Expand Up @@ -771,7 +788,17 @@ def attach(self, pid: int, name: str) -> Dict[str, Any]:
"Could not open pid %d: %s. %s" % (pid, error, _permission_hint())
) from None

session = self.store.open(process, pid, name)
try:
session = self.store.open(process, pid, name)
except SessionError:
# The handle already exists and the store's cap is checked after
# it does, so refusing without closing leaks the resource the cap
# protects.
try:
process.close()
except Exception: # noqa: BLE001 — target may already be gone
pass
raise

result: Dict[str, Any] = {
"opened": True,
Expand All @@ -797,6 +824,10 @@ def open_process(self, pid: int = 0, name: str = "") -> Dict[str, Any]:
reopening the same target, since each open costs a handle and resets
the cached region map.

At most ``max_open_sessions`` (see ``server_info``) can be open at
once; reaching it is refused, not rotated, so ``close_process`` a
target you are done with.

Attaching to a process the operator has not pre-approved requires the
**user's** approval, asked for at the moment you call this. Say which
process you want and why; if the request is refused, report that rather
Expand Down Expand Up @@ -2001,7 +2032,8 @@ def _read_addresses(
"""Yield ``(address, value | None)`` for each address, in one pass.

Uses ``search_by_addresses``, which groups the reads by region so a
50 000-address refine is a few hundred syscalls rather than 50 000.
refine of a capped result set is a few hundred syscalls rather than one
per address.
"""
yield from session.process.search_by_addresses(
pytype,
Expand Down
4 changes: 2 additions & 2 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ pymemoryeditor-mcp --allow-any-process # never asks at all (scripts/CI)
</tr>
<tr>
<td><code>--max-scan-results N</code></td>
<td>Addresses one scan may keep. Default 50 000.</td>
<td>Addresses one scan may keep. Default 100 000.</td>
</tr>
<tr>
<td><code>--max-scan-seconds S</code></td>
Expand All @@ -129,7 +129,7 @@ pymemoryeditor-mcp --allow-any-process # never asks at all (scripts/CI)
<tr><th width="30%">Tool</th><th>What it does</th></tr>
<tr><td><code>server_info</code></td><td>Capabilities, limits, policy and open sessions. The assistant should call this first.</td></tr>
<tr><td><code>list_processes</code></td><td>Running processes the server is allowed to open.</td></tr>
<tr><td><code>open_process</code></td><td>Attach by pid or name → a <code>session_id</code>.</td></tr>
<tr><td><code>open_process</code></td><td>Attach by pid or name → a <code>session_id</code>. Eight targets can be open at once; past that it refuses rather than rotating one out, since a handle the assistant still holds must not vanish under it.</td></tr>
Comment thread
JeanExtreme002 marked this conversation as resolved.
<tr><td><code>close_process</code></td><td>Detach and drop that session's scan results.</td></tr>
<tr><td><code>process_info</code></td><td>Bitness, address-space summary, modules and threads.</td></tr>
<tr><td><code>list_memory_regions</code></td><td>Page through the memory map, filtered by permission or backing file.</td></tr>
Expand Down
Loading
Loading