-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaddon.py
More file actions
234 lines (188 loc) · 8.24 KB
/
Copy pathaddon.py
File metadata and controls
234 lines (188 loc) · 8.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
"""The script Origin runs to start the bridge.
Loaded with ``run -pyf`` from Origin's command window or from an Origin App, so
it executes inside Origin's own Python interpreter -- and it does not return
until the bridge stops. That is not an oversight. The serving loop has to own
Origin's UI thread, because originpro deadlocks the embedded interpreter when
called from any other one, so this call blocks for as long as the bridge runs.
Kept as thin as it can be, and what is here is what cannot be reloaded from
elsewhere. Origin caches imported modules for the lifetime of the process, so
this file drops the package before importing it -- restarting the bridge is
then enough to pick up a change, without restarting Origin. The operations
layer goes further and can be swapped while the bridge is still serving, which
is why `build_operations` lives here: it has to outlive the reload it performs.
What this prints goes to Origin's Script Window, which Origin brings forward on
its own -- so someone sitting at the machine sees the bridge start without
being told where to look. The status file beside this script is for everyone
else: the MCP client cannot connect to a bridge that never started, and when
Origin is in a VM the Script Window is not somewhere the operator can see. That
file is how the App's own bridge was confirmed to be serving, read over ssh
from another machine.
"""
from __future__ import annotations
import contextlib
import json
import os
import platform
import sys
import time
import traceback
from pathlib import Path
from typing import Any
HERE = Path(__file__).resolve().parent
STATUS_PATH = HERE / "bridge-status.json"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 47631
_status: dict[str, Any] = {}
def _record(phase: str, **fields: Any) -> None:
"""Write down where we got to, so a failure has somewhere to be seen."""
_status.update(phase=phase, updated_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
_status.update(fields)
# If even this cannot be written there is nothing left to try; losing the
# diagnostic must not be what stops the bridge from starting.
with contextlib.suppress(OSError):
STATUS_PATH.write_text(
json.dumps(_status, indent=2, sort_keys=True, default=str) + "\n",
encoding="utf-8",
)
print(f"[originlab-mcp] {phase}", flush=True)
PACKAGE = "originlab_mcp"
def _import_package() -> Any:
"""Make the package importable from beside this file, and load it."""
src = HERE / "src"
if src.is_dir() and str(src) not in sys.path:
sys.path.insert(0, str(src))
import originlab_mcp
return originlab_mcp
def drop_cached_package() -> int:
"""Forget any previously loaded copy of the package; returns how many went.
Origin keeps imported modules for the lifetime of the process, so a second
``run -pyf`` after an edit quietly re-runs the previous version. Observed
live: a rebuilt operation kept returning its old response shape until
Origin itself was restarted. Dropping our modules first makes restarting
the bridge enough to pick up a change.
Called from the script entry point rather than from ``start``, because "the
file on disk may have changed since last time" is true exactly when Origin
runs this file, and not when something already running calls in. Doing it
inside ``start`` would also swap the classes out from under a caller that
had already imported them.
Only our own modules go. Purging anything else would be reaching into an
interpreter shared with Origin and with whatever the user runs in it, and
originpro in particular holds live handles to Origin objects that must not
be re-initialised underneath it.
"""
stale = [name for name in sys.modules if name == PACKAGE or name.startswith(f"{PACKAGE}.")]
for name in stale:
del sys.modules[name]
return len(stale)
OPERATIONS_PACKAGE = f"{PACKAGE}.ops"
def build_operations(server: Any) -> dict[str, Any]:
"""Assemble what the bridge answers, including how to rebuild this.
Lives here rather than in the package because it must survive the reload it
performs. The ops package is imported inside the function, so each rebuild
picks up whatever is on disk now -- and because the whole package is
dropped, a file added there is reloadable without anyone listing it.
Only the operations layer is swapped. The transport cannot reload itself --
the server object is running -- and `protocol` must not be reloaded either,
or the exception classes the running dispatcher catches would stop being the
ones fresh operations raise. Since the operations layer is where the Origin
work lives, that boundary falls in the useful place.
"""
from originlab_mcp.ops._arguments import accepts
from originlab_mcp.protocol import ErrorCode, ProtocolError
@accepts()
def op_reload(_args: dict[str, Any]) -> dict[str, Any]:
previous = dict(server.operations)
for name in [
n
for n in sys.modules
if n == OPERATIONS_PACKAGE or n.startswith(f"{OPERATIONS_PACKAGE}.")
]:
del sys.modules[name]
try:
server.operations = build_operations(server)
except Exception as exc:
# A typo in the new file must not leave the bridge with nothing to
# answer. Keep serving the old operations and say what went wrong.
server.operations = previous
raise ProtocolError(
ErrorCode.INTERNAL_ERROR,
f"Reload failed, still serving the previously loaded operations: "
f"{type(exc).__name__}: {exc}",
) from exc
_record("reloaded", operations=sorted(server.operations))
return {"reloaded": True, "operations": sorted(server.operations)}
from originlab_mcp.ops import build_registry
operations = build_registry(on_shutdown=server.request_shutdown)
operations["reload"] = op_reload
return operations
def start(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> dict[str, Any]:
"""Start the bridge and serve until it is asked to stop."""
_status.clear()
_record(
"starting",
pid=os.getpid(),
python=platform.python_version(),
executable=sys.executable,
status_path=str(STATUS_PATH),
)
try:
package = _import_package()
from originlab_mcp import handshake as hs
from originlab_mcp import serving
from originlab_mcp.bridge_server import BridgeServer
except Exception as exc:
_record("failed", error=f"{type(exc).__name__}: {exc}", traceback=traceback.format_exc())
raise
_record("loaded", version=package.__version__)
token = hs.token_override() or hs.new_token()
session = hs.new_session()
try:
server = BridgeServer((host, port), token=token, session=session, operations={})
server.operations = build_operations(server)
except Exception as exc:
_record(
"failed",
error=f"{type(exc).__name__}: {exc}",
traceback=traceback.format_exc(),
hint=(
f"Could not listen on {host}:{port}. Another bridge may already be "
"running in this or another Origin instance."
),
)
raise
bound_host, bound_port = server.bound
handshake_path = hs.write(
hs.Handshake(
host=bound_host,
port=bound_port,
token=token,
session=session,
pid=os.getpid(),
updated_at=hs.utc_stamp(),
)
)
_record(
"serving",
host=bound_host,
port=bound_port,
session=session,
handshake_path=str(handshake_path),
note="This Python call does not return until the bridge stops.",
)
try:
report = serving.serve(server)
finally:
hs.clear()
_record(
"stopped",
stopped_by=report.stopped_by,
passes=report.passes,
messages_dispatched=report.messages_dispatched,
)
return {"stopped_by": report.stopped_by}
if __name__ == "__main__":
drop_cached_package()
start(
host=os.environ.get("ORIGINLAB_MCP_HOST", DEFAULT_HOST),
port=int(os.environ.get("ORIGINLAB_MCP_PORT", DEFAULT_PORT)),
)