oid-mcp 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: oid-mcp
3
+ Version: 0.3.0
4
+ Summary: MCP server giving AI agents eyes on OpenImageDebugger buffers in live gdb/lldb sessions
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: mcp>=1.2
7
+ Requires-Dist: numpy>=1.24
8
+ Requires-Dist: pillow>=10.0
@@ -0,0 +1,34 @@
1
+ oidmcp/__init__.py,sha256=wpWx6dgrfBtzt5P4RmrSn-sqzKF33m0miL_ed8R0jnc,446
2
+ oidmcp/_wireframe.py,sha256=LCEd7EVUfvHV6belQAUdewlCvO4qJTSrOQ8CR6rVqxw,3991
3
+ oidmcp/analysis.py,sha256=MQTpb4N8-pcgebZ-Rm7h4EjMEnPw0H8EJYE-u3Lk6wA,7228
4
+ oidmcp/buffers.py,sha256=ifY9YJIUwWiG2aqJRt-RdHPzuwMbFk-sF-MrCplMc7A,3191
5
+ oidmcp/discovery.py,sha256=5tHb3Cd3f3-oScDBhfFyBE6jbDKoz8lmgKJVzAz0ics,9089
6
+ oidmcp/protocol.py,sha256=j1A7PLvQzNlY6cBa_UnrfMEXDiUIm17sL-CmR7ZDzjk,5020
7
+ oidmcp/render.py,sha256=TfTlgS7QlasMxZaz-Gh6G0zN7U-cak_D1b1oa5_mBao,5006
8
+ oidmcp/server.py,sha256=dqsLeeT_lE8k3i-DmNBC2BYJSY1hDzx1tdq4nTS_bjA,18944
9
+ oidmcp/viewer_meta.py,sha256=cjblAAOZnL109BxXA9CGD9j-OR5g0Qmjj0eKOw6KS90,1505
10
+ oidscripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ oidscripts/agentendpoint.py,sha256=hqciAuHk5J_KskIkpkNikW_v3pbSSKsgRoeMyL8oCWc,17075
12
+ oidscripts/events.py,sha256=LrjXd-JuCjNevbRpLbg6MjHeCzUJQ64032ILpjs3kTY,1897
13
+ oidscripts/logger.py,sha256=VV7uecLi7uZKJoyq5sJNoyfMiH-isAsI5WclpFY0h-U,403
14
+ oidscripts/oidwindow.py,sha256=8wzH8l_ms7MuHPQm84b7aGgcpHoMBg1OW56HI3cwCo8,7375
15
+ oidscripts/symbols.py,sha256=Rk5LrR32UwQTM5EQHhx17MZv1oVTQ_oCWdI01i4Q2rA,244
16
+ oidscripts/sysinfo.py,sha256=cfrW-ZsSP5KGFBDzqyx9a85U2J_6rnLQTHmSMzraXXs,4455
17
+ oidscripts/test.py,sha256=l6cQUZ1ON2Y2Vo1kSgskebL0IH3FTdB-b8Yeai5eyk4,5834
18
+ oidscripts/typebridge.py,sha256=aWTyyvl2zylCSsLXEVcUPB9QyHmXJuttNRH5ygk4wdU,1898
19
+ oidscripts/wireframe.py,sha256=1LzXlex4IVly9L3gad-SwIuD4xS_IDiW7plcASx49-A,3503
20
+ oidscripts/debuggers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ oidscripts/debuggers/gdbbridge.py,sha256=2sg4_78hWCXIRgF2MpXnRqp-sQm3NBp955i3a3SK-r8,6393
22
+ oidscripts/debuggers/interfaces.py,sha256=kUhL3kopVmsq6057a2vstFlG2gj422t4Arn3Q_dqjTg,5553
23
+ oidscripts/debuggers/lldbbridge.py,sha256=gKCxlwIt89nMIr5rEGYkfWVA8lW279eksQutNzqPHnA,10307
24
+ oidscripts/debuggers/template_args.py,sha256=23EHXaXY6QO_rjwRAXGnbbes1cNzWICYuXZ1xanLlDM,3509
25
+ oidscripts/ides/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
+ oidscripts/ides/qtcreator.py,sha256=50-RF_QMmWr2TY42Gg0OnOSIBWanMdNcRtJUtGn1v0w,2141
27
+ oidscripts/oidtypes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
+ oidscripts/oidtypes/eigen3.py,sha256=Eh1HwmMX5V23CpWGRgxHIMmbLA5nxBnpIoKQ0GTEkNU,6075
29
+ oidscripts/oidtypes/interface.py,sha256=juZ8UiK2QE6j-Gjl_PH69BsFnR-fMlgkQEjqRsN_Q-4,2937
30
+ oidscripts/oidtypes/opencv.py,sha256=AqHsgxUi0QWzYB3BPLigOj4OESexRpauUw0DlghwIZQ,5989
31
+ oid_mcp-0.3.0.dist-info/METADATA,sha256=DpWfOwoMOZDZ5RisLbpRf4qnSF2_Gmek7CPWc78fG0A,251
32
+ oid_mcp-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
33
+ oid_mcp-0.3.0.dist-info/entry_points.txt,sha256=6g2FzW2uUb8W6JbijKHRXZ31d83ZtDLl26-u8-tDJeg,47
34
+ oid_mcp-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ oid-mcp = oidmcp.server:main
oidmcp/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """MCP server exposing OpenImageDebugger buffer inspection to AI agents.
2
+
3
+ The stdlib-only wire framing is defined once, in
4
+ ``oidscripts.wireframe``, and shared with the in-debugger endpoint. The
5
+ ``oidmcp._wireframe`` bridge re-exports it, appending the sibling
6
+ debugger-scripts tree to ``sys.path`` (idempotent and lowest-precedence)
7
+ so it imports as a normal named package rather than being loaded by
8
+ filesystem path.
9
+ """
10
+
11
+ __version__ = "0.2.0"
oidmcp/_wireframe.py ADDED
@@ -0,0 +1,81 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """Bridge to the single source of the wire framing: ``oidscripts.wireframe``.
4
+
5
+ oid-mcp and the in-debugger endpoint must agree byte-for-byte on the wire
6
+ format. Rather than keep a second copy in step by hand, both sides import
7
+ the one module that defines it. oid-mcp runs from the deployed OID
8
+ resources tree (``uv run --directory resources/oidmcp``), where
9
+ ``resources/oidscripts`` is a sibling of this package, so this bridge puts
10
+ that directory on ``sys.path`` and re-exports the shared helpers as a
11
+ normal named-package import -- no code is loaded by filesystem path.
12
+
13
+ The append is idempotent and lowest-precedence, so it cannot shadow a
14
+ stdlib or site module; it mirrors what the test suite's conftest already
15
+ does to import from the debugger-scripts tree. That low precedence,
16
+ however, also means an unrelated ``oidscripts`` earlier on ``sys.path``
17
+ could win the import, and a foreign ``wireframe`` may carry an
18
+ incompatible wire format. So the resolved module is verified to be the one
19
+ shipped beside us and a mismatch is rejected loudly rather than used
20
+ silently.
21
+ """
22
+
23
+ import os
24
+ import sys
25
+ import sysconfig
26
+ from pathlib import Path
27
+
28
+ # Three supported layouts for the shared scripts tree:
29
+ # repo layout: resources/oidmcp/oidmcp/_wireframe.py with the tree at
30
+ # resources/oidscripts (parents[2] == resources/)
31
+ # installed layout: site-packages/oidmcp/_wireframe.py with oidscripts
32
+ # shipped in the same site-packages by the wheel
33
+ # (parents[1] == site-packages)
34
+ # editable-install layout: a local `uv run`/`pip install -e` dev sync
35
+ # redirects oidmcp/_wireframe.py's own __file__ back to
36
+ # this repo file (so parents[1] above is not the venv's
37
+ # site-packages), yet the build backend still has to
38
+ # materialize the force-included oidscripts tree as a
39
+ # real copy in the running interpreter's site-packages,
40
+ # since there is no editable/redirect mechanism for a
41
+ # force-include mapped from outside the project root.
42
+ _RESOURCES_DIR = Path(__file__).resolve().parents[2]
43
+ _SITE_DIR = Path(__file__).resolve().parents[1]
44
+ _PURELIB_DIR = Path(sysconfig.get_path('purelib'))
45
+ # Single-sourced to avoid repeating the path components below.
46
+ _OIDSCRIPTS_WIREFRAME = Path('oidscripts') / 'wireframe.py'
47
+ _EXPECTED_WIREFRAMES = (
48
+ _RESOURCES_DIR / _OIDSCRIPTS_WIREFRAME,
49
+ _SITE_DIR / _OIDSCRIPTS_WIREFRAME,
50
+ _PURELIB_DIR / _OIDSCRIPTS_WIREFRAME,
51
+ )
52
+ if str(_RESOURCES_DIR) not in sys.path:
53
+ sys.path.append(str(_RESOURCES_DIR))
54
+
55
+ try:
56
+ from oidscripts import wireframe as _wireframe # noqa: E402
57
+ except ImportError as exc: # pragma: no cover - deployment misconfiguration
58
+ raise ImportError(
59
+ 'oid-mcp requires the OpenImageDebugger scripts tree; expected '
60
+ 'oidscripts alongside this package under resources/. Run oid-mcp '
61
+ 'from the OID install (uv run --directory resources/oidmcp).'
62
+ ) from exc
63
+
64
+ # Guard against an earlier sys.path entry resolving `oidscripts` to some
65
+ # other tree: confirm we imported the wireframe that ships beside us. A
66
+ # different module could encode frames incompatibly, so treat it as a
67
+ # misconfiguration to fix, not a protocol to trust.
68
+ _loaded = getattr(_wireframe, '__file__', None)
69
+ if _loaded is not None and os.path.realpath(_loaded) not in {
70
+ os.path.realpath(str(p)) for p in _EXPECTED_WIREFRAMES}:
71
+ raise ImportError( # pragma: no cover - conflicting oidscripts on path
72
+ 'oid-mcp imported a foreign oidscripts.wireframe %s; expected '
73
+ 'one of %s. Remove the conflicting oidscripts from sys.path / '
74
+ 'PYTHONPATH.'
75
+ % (_loaded, [str(p) for p in _EXPECTED_WIREFRAMES]))
76
+
77
+ MAX_FRAME_BYTES = _wireframe.MAX_FRAME_BYTES
78
+ recv_frame = _wireframe.recv_frame
79
+ send_frame = _wireframe.send_frame
80
+
81
+ __all__ = ['MAX_FRAME_BYTES', 'recv_frame', 'send_frame']
oidmcp/analysis.py ADDED
@@ -0,0 +1,201 @@
1
+ """Exact-number inspection: stats, value crops, lossless dumps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import math
7
+ import os
8
+ import re
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+
14
+ from .buffers import crop_region
15
+
16
+ VALUES_CAP = 1024
17
+
18
+
19
+ def _finite_or_none(value):
20
+ value = float(value)
21
+ return value if math.isfinite(value) else None
22
+
23
+
24
+ def _channel_stats(channel: np.ndarray, floating: bool, label) -> dict:
25
+ """Nan-aware stat dict for one 2-D channel (all-NaN -> None stats)."""
26
+ finite = channel[np.isfinite(channel)] if floating else channel
27
+ empty = finite.size == 0
28
+ return {
29
+ 'label': label,
30
+ 'min': None if empty else _finite_or_none(finite.min()),
31
+ 'max': None if empty else _finite_or_none(finite.max()),
32
+ 'mean': None if empty else _finite_or_none(finite.mean()),
33
+ 'std': None if empty else _finite_or_none(finite.std()),
34
+ 'nan': int(np.isnan(channel).sum()) if floating else 0,
35
+ 'inf': int(np.isinf(channel).sum()) if floating else 0,
36
+ 'zeros': int((channel == 0).sum()),
37
+ 'count': int(channel.size),
38
+ }
39
+
40
+
41
+ def _as_hwc(arr: np.ndarray) -> np.ndarray:
42
+ """
43
+ Normalize a buffer to (H, W, C). A bare (H, W) matrix is a
44
+ single-channel buffer, so promote it to (H, W, 1); reject anything
45
+ that is neither 2-D nor 3-D with a clear error.
46
+ """
47
+ if arr.ndim == 2:
48
+ return arr[:, :, np.newaxis]
49
+ if arr.ndim != 3:
50
+ raise ValueError(
51
+ f'expected a 2-D or 3-D buffer, got {arr.ndim} dimensions')
52
+ return arr
53
+
54
+
55
+ def compute_stats(arr: np.ndarray, meta: dict,
56
+ region: tuple | None = None) -> dict:
57
+ arr = _as_hwc(arr)
58
+ view = crop_region(arr, region) if region is not None else arr
59
+ floating = np.issubdtype(arr.dtype, np.floating)
60
+ layout = (meta.get('pixel_layout') or '')
61
+ if arr.shape[2] == 1:
62
+ # A single-channel buffer (an Eigen matrix, a mask) has no
63
+ # color-channel semantics; do not label it after the layout.
64
+ layout = ''
65
+ per_channel = []
66
+ for index in range(view.shape[2]):
67
+ channel = view[:, :, index]
68
+ label = layout[index] if index < len(layout) else None
69
+ stats = {'channel': index, **_channel_stats(channel, floating, label)}
70
+ per_channel.append(stats)
71
+ return {
72
+ 'width': arr.shape[1],
73
+ 'height': arr.shape[0],
74
+ 'channels': arr.shape[2],
75
+ 'dtype': str(arr.dtype),
76
+ 'region': list(region) if region is not None else None,
77
+ 'per_channel': per_channel,
78
+ }
79
+
80
+
81
+ def _json_safe(value):
82
+ if isinstance(value, float) and not math.isfinite(value):
83
+ if math.isnan(value):
84
+ return 'NaN'
85
+ return 'Inf' if value > 0 else '-Inf'
86
+ return value
87
+
88
+
89
+ def _sanitize_tree(node):
90
+ if isinstance(node, list):
91
+ return [_sanitize_tree(item) for item in node]
92
+ if isinstance(node, dict):
93
+ return {key: _sanitize_tree(value) for key, value in node.items()}
94
+ return _json_safe(node)
95
+
96
+
97
+ def extract_values(arr: np.ndarray, x: int, y: int, w: int, h: int,
98
+ channel: int | None = None) -> dict:
99
+ """
100
+ Exact values for a crop; capped so results stay readable.
101
+ """
102
+ per_pixel = 1 if channel is not None else arr.shape[2]
103
+ total = w * h * per_pixel
104
+ if total > VALUES_CAP:
105
+ raise ValueError(
106
+ f'{w}x{h} crop holds {total} values, over the {VALUES_CAP} '
107
+ f'cap; request a smaller region or a single channel')
108
+ view = crop_region(arr, (x, y, w, h))
109
+ if channel is not None:
110
+ if not 0 <= channel < arr.shape[2]:
111
+ raise ValueError(
112
+ f'channel {channel} out of range; buffer has '
113
+ f'{arr.shape[2]}')
114
+ view = view[:, :, channel]
115
+ return {
116
+ 'x': x, 'y': y, 'w': w, 'h': h,
117
+ 'channel': channel,
118
+ 'dtype': str(arr.dtype),
119
+ 'values': _sanitize_tree(view.tolist()),
120
+ }
121
+
122
+
123
+ def _current_user() -> str:
124
+ try:
125
+ return getpass.getuser()
126
+ except Exception:
127
+ return str(os.getuid()) if hasattr(os, 'getuid') else 'user'
128
+
129
+
130
+ def _safe_dump_dir() -> Path:
131
+ """
132
+ Per-user default dump directory, hardened against a shared tempdir:
133
+ reject a pre-created symlink or a directory owned by another user,
134
+ and force 0o700 (mkdir ignores the mode when the dir pre-exists).
135
+ """
136
+ directory = Path(tempfile.gettempdir()) / ('oid-dumps-' + _current_user())
137
+ if directory.is_symlink():
138
+ raise RuntimeError('dump directory %s is a symlink' % directory)
139
+ directory.mkdir(mode=0o700, exist_ok=True)
140
+ if hasattr(os, 'getuid'):
141
+ info = os.stat(directory)
142
+ if info.st_uid != os.getuid():
143
+ raise RuntimeError(
144
+ 'dump directory %s is owned by another user' % directory)
145
+ os.chmod(directory, 0o700)
146
+ return directory
147
+
148
+
149
+ def dump_npy(arr: np.ndarray, symbol: str, stop_generation: int,
150
+ path: str | None = None, overwrite: bool = False) -> str:
151
+ """
152
+ Save the full decoded buffer (padding already stripped) as .npy.
153
+
154
+ Every write is confined to the hardened per-user dump directory
155
+ (``_safe_dump_dir()``); ``path`` may only pick a bare filename
156
+ inside it, never an arbitrary location. The write itself is atomic
157
+ and symlink-safe.
158
+
159
+ Never clobbers an existing file: if the resolved target already
160
+ exists, raise unless ``overwrite`` is set, so an unintended ``path``
161
+ cannot silently destroy the caller's data.
162
+ """
163
+ directory = _safe_dump_dir()
164
+ if path is None:
165
+ name = re.sub(r'[^A-Za-z0-9_.-]', '_', symbol)
166
+ filename = f'{name}_gen{stop_generation}.npy'
167
+ else:
168
+ # The client picks only a name INSIDE the hardened dump dir; a
169
+ # path separator, '..', or an absolute path could escape it.
170
+ if (os.path.isabs(path)
171
+ or os.path.basename(path) != path
172
+ or path in ('.', '..', '')):
173
+ raise ValueError(
174
+ 'dump path must be a bare filename within the dump '
175
+ 'directory')
176
+ filename = path if path.endswith('.npy') else f'{path}.npy'
177
+ target = os.path.join(str(directory), filename)
178
+ if os.path.islink(target):
179
+ raise ValueError(
180
+ f'{target} is a symlink; refusing to write through it')
181
+ if not overwrite and os.path.lexists(target):
182
+ raise FileExistsError(
183
+ f'{target} already exists; pass overwrite=true to replace it')
184
+ # Atomic, symlink-safe write: fill a temp file in the same 0700 dir,
185
+ # then os.replace() the directory entry (which never follows a
186
+ # symlink at the target).
187
+ fd, tmp = tempfile.mkstemp(dir=str(directory), suffix='.npy.tmp')
188
+ try:
189
+ with os.fdopen(fd, 'wb') as handle:
190
+ np.save(handle, np.ascontiguousarray(arr))
191
+ os.replace(tmp, target)
192
+ except BaseException:
193
+ try:
194
+ os.unlink(tmp)
195
+ except OSError:
196
+ # Best-effort cleanup of the temp file; a failure to unlink it
197
+ # (already gone, or a transient FS error) must not mask the
198
+ # original write error we are about to re-raise.
199
+ pass
200
+ raise
201
+ return os.path.abspath(target)
oidmcp/buffers.py ADDED
@@ -0,0 +1,93 @@
1
+ """Decode raw endpoint payloads into numpy arrays."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+
7
+ import numpy as np
8
+
9
+ # Keep in sync with oidscripts/symbols.py (OID_TYPES_*). Value 1 is
10
+ # historically unused.
11
+ DTYPES = {
12
+ 0: np.uint8,
13
+ 2: np.uint16,
14
+ 3: np.int16,
15
+ 4: np.int32,
16
+ 5: np.float32,
17
+ 6: np.float64,
18
+ }
19
+
20
+
21
+ def decode_buffer(meta: dict, raw: bytes) -> np.ndarray:
22
+ """
23
+ Return an (H, W, C) array in display orientation; channel order is
24
+ the storage order (pixel_layout is applied only when rendering).
25
+
26
+ Metadata semantics (matching the debugger bridges): width/height/
27
+ row_stride describe the buffer as stored; row_stride counts pixels,
28
+ including padding; transpose_buffer means the stored rows run along
29
+ the display's width axis.
30
+ """
31
+ if meta['type'] not in DTYPES:
32
+ raise ValueError(f"unsupported buffer type {meta['type']}")
33
+ dtype = np.dtype(DTYPES[meta['type']])
34
+ height, width = int(meta['height']), int(meta['width'])
35
+ channels = int(meta['channels'])
36
+ stride = int(meta['row_stride'])
37
+ if height <= 0 or width <= 0 or channels <= 0:
38
+ raise ValueError(
39
+ f'invalid buffer dimensions: {width}x{height}x{channels}')
40
+ if stride < width:
41
+ raise ValueError(
42
+ f'row_stride {stride} is smaller than width {width}')
43
+ count = height * stride * channels
44
+ if len(raw) < count * dtype.itemsize:
45
+ raise ValueError(
46
+ f'buffer payload is {len(raw)} bytes; expected at least '
47
+ f'{count * dtype.itemsize}')
48
+ arr = np.frombuffer(raw, dtype=dtype, count=count)
49
+ arr = arr.reshape(height, stride, channels)[:, :width, :]
50
+ if meta.get('transpose_buffer'):
51
+ arr = arr.transpose(1, 0, 2)
52
+ return arr
53
+
54
+
55
+ def crop_region(arr: np.ndarray,
56
+ region: tuple[int, int, int, int]) -> np.ndarray:
57
+ """
58
+ Crop (x, y, w, h), in display coordinates (x = column, y = row).
59
+ """
60
+ if len(region) != 4:
61
+ raise ValueError('region must be [x, y, w, h]')
62
+ x, y, w, h = (int(v) for v in region)
63
+ rows, cols = arr.shape[:2]
64
+ if w <= 0 or h <= 0 or x < 0 or y < 0 or x + w > cols or y + h > rows:
65
+ raise ValueError(
66
+ f'region (x={x}, y={y}, w={w}, h={h}) is outside the '
67
+ f'{cols}x{rows} buffer')
68
+ return arr[y:y + h, x:x + w, :]
69
+
70
+
71
+ # (session pid, token, symbol, stop generation) -> (metadata, decoded array)
72
+ CacheKey = tuple[int, str, str, int]
73
+ CacheValue = tuple[dict, np.ndarray]
74
+
75
+
76
+ class BufferCache:
77
+ """Small LRU keyed by (session pid, token, symbol, stop generation)."""
78
+
79
+ def __init__(self, capacity: int = 4):
80
+ self._capacity = capacity
81
+ self._entries: OrderedDict[CacheKey, CacheValue] = OrderedDict()
82
+
83
+ def get(self, key: CacheKey) -> CacheValue | None:
84
+ if key not in self._entries:
85
+ return None
86
+ self._entries.move_to_end(key)
87
+ return self._entries[key]
88
+
89
+ def put(self, key: CacheKey, value: CacheValue) -> None:
90
+ self._entries[key] = value
91
+ self._entries.move_to_end(key)
92
+ while len(self._entries) > self._capacity:
93
+ self._entries.popitem(last=False)