rocket-engine 0.0.2b1__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.
Files changed (241) hide show
  1. rocket_engine/__init__.py +8 -0
  2. rocket_engine/__main__.py +12 -0
  3. rocket_engine/adapters/README.md +54 -0
  4. rocket_engine/adapters/__init__.py +7 -0
  5. rocket_engine/adapters/api.py +39 -0
  6. rocket_engine/adapters/base.py +46 -0
  7. rocket_engine/adapters/cace/__init__.py +163 -0
  8. rocket_engine/adapters/circulax/README.md +10 -0
  9. rocket_engine/adapters/circulax/__init__.py +112 -0
  10. rocket_engine/adapters/circulax/_driver.py +190 -0
  11. rocket_engine/adapters/circulax/adapter.py +538 -0
  12. rocket_engine/adapters/circulax/results.py +291 -0
  13. rocket_engine/adapters/cli/README.md +34 -0
  14. rocket_engine/adapters/cli/__init__.py +305 -0
  15. rocket_engine/adapters/connectivity/__init__.py +196 -0
  16. rocket_engine/adapters/connectivity/results.py +214 -0
  17. rocket_engine/adapters/cosim/README.md +9 -0
  18. rocket_engine/adapters/cosim/__init__.py +1390 -0
  19. rocket_engine/adapters/cosim/results.py +214 -0
  20. rocket_engine/adapters/devsim/__init__.py +171 -0
  21. rocket_engine/adapters/digital_flow/README.md +9 -0
  22. rocket_engine/adapters/digital_flow/__init__.py +1121 -0
  23. rocket_engine/adapters/digital_flow/results.py +227 -0
  24. rocket_engine/adapters/digital_flow/state.py +462 -0
  25. rocket_engine/adapters/drc/README.klayout.md +7 -0
  26. rocket_engine/adapters/drc/README.md +7 -0
  27. rocket_engine/adapters/drc/__init__.py +1 -0
  28. rocket_engine/adapters/drc/klayout.py +952 -0
  29. rocket_engine/adapters/drc/magic.py +287 -0
  30. rocket_engine/adapters/drc/results.py +184 -0
  31. rocket_engine/adapters/drc/results_kl.py +188 -0
  32. rocket_engine/adapters/elmer/__init__.py +163 -0
  33. rocket_engine/adapters/envelope.py +120 -0
  34. rocket_engine/adapters/equivalence/__init__.py +156 -0
  35. rocket_engine/adapters/errors.py +181 -0
  36. rocket_engine/adapters/extract_magic/README.md +15 -0
  37. rocket_engine/adapters/extract_magic/__init__.py +425 -0
  38. rocket_engine/adapters/extract_magic/results.py +209 -0
  39. rocket_engine/adapters/formal_sby/README.md +7 -0
  40. rocket_engine/adapters/formal_sby/__init__.py +773 -0
  41. rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
  42. rocket_engine/adapters/formal_sby/results.py +185 -0
  43. rocket_engine/adapters/gdsfactory/__init__.py +194 -0
  44. rocket_engine/adapters/gdsfactory/_driver.py +459 -0
  45. rocket_engine/adapters/gdsfactory/adapter.py +609 -0
  46. rocket_engine/adapters/gdsfactory/results.py +181 -0
  47. rocket_engine/adapters/hdl_sim/README.md +7 -0
  48. rocket_engine/adapters/hdl_sim/__init__.py +369 -0
  49. rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
  50. rocket_engine/adapters/hdl_sim/results.py +178 -0
  51. rocket_engine/adapters/hdl_sim/verilator.py +167 -0
  52. rocket_engine/adapters/http/README.md +15 -0
  53. rocket_engine/adapters/http/__init__.py +458 -0
  54. rocket_engine/adapters/http/events.py +63 -0
  55. rocket_engine/adapters/ipc/README.md +9 -0
  56. rocket_engine/adapters/ipc/__init__.py +111 -0
  57. rocket_engine/adapters/lvs_netgen/README.md +20 -0
  58. rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
  59. rocket_engine/adapters/lvs_netgen/results.py +168 -0
  60. rocket_engine/adapters/mcp/README.md +19 -0
  61. rocket_engine/adapters/mcp/__init__.py +85 -0
  62. rocket_engine/adapters/mcp/api-schema.json +6107 -0
  63. rocket_engine/adapters/mcp/pull.py +246 -0
  64. rocket_engine/adapters/mcp/server.py +794 -0
  65. rocket_engine/adapters/meep/__init__.py +167 -0
  66. rocket_engine/adapters/meow/README.md +7 -0
  67. rocket_engine/adapters/meow/__init__.py +190 -0
  68. rocket_engine/adapters/meow/_driver.py +227 -0
  69. rocket_engine/adapters/meow/_envelope.py +108 -0
  70. rocket_engine/adapters/meow/adapter.py +491 -0
  71. rocket_engine/adapters/meow/results.py +218 -0
  72. rocket_engine/adapters/mutation/__init__.py +192 -0
  73. rocket_engine/adapters/ngspice/README.md +28 -0
  74. rocket_engine/adapters/ngspice/__init__.py +627 -0
  75. rocket_engine/adapters/ngspice/results.py +215 -0
  76. rocket_engine/adapters/postlayout/README.md +28 -0
  77. rocket_engine/adapters/postlayout/__init__.py +17 -0
  78. rocket_engine/adapters/postlayout/adapter.py +1062 -0
  79. rocket_engine/adapters/postlayout/results.py +330 -0
  80. rocket_engine/adapters/postlayout/translate.py +71 -0
  81. rocket_engine/adapters/profile.py +220 -0
  82. rocket_engine/adapters/sax/README.md +23 -0
  83. rocket_engine/adapters/sax/__init__.py +516 -0
  84. rocket_engine/adapters/sax/_driver.py +148 -0
  85. rocket_engine/adapters/sax/results.py +291 -0
  86. rocket_engine/adapters/stdio_console.py +1151 -0
  87. rocket_engine/adapters/syntax/README.md +5 -0
  88. rocket_engine/adapters/syntax/__init__.py +366 -0
  89. rocket_engine/adapters/syntax/results.py +181 -0
  90. rocket_engine/adapters/synth_yosys/README.md +7 -0
  91. rocket_engine/adapters/synth_yosys/__init__.py +655 -0
  92. rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
  93. rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
  94. rocket_engine/adapters/synth_yosys/results.py +173 -0
  95. rocket_engine/adapters/timing_sta/README.md +9 -0
  96. rocket_engine/adapters/timing_sta/__init__.py +726 -0
  97. rocket_engine/adapters/view_sync/__init__.py +185 -0
  98. rocket_engine/adapters/view_sync/results.py +189 -0
  99. rocket_engine/adapters/vlsir/__init__.py +142 -0
  100. rocket_engine/adapters/xschem/__init__.py +440 -0
  101. rocket_engine/adapters/xschem/deck.py +266 -0
  102. rocket_engine/clients/__init__.py +5 -0
  103. rocket_engine/clients/runtime.py +255 -0
  104. rocket_engine/composition.py +2279 -0
  105. rocket_engine/execution/README.md +30 -0
  106. rocket_engine/execution/__init__.py +150 -0
  107. rocket_engine/execution/apig/__init__.py +1 -0
  108. rocket_engine/execution/apig/pool.py +463 -0
  109. rocket_engine/execution/apig/submit.py +124 -0
  110. rocket_engine/execution/block_runs_scope.py +479 -0
  111. rocket_engine/execution/coalesce.py +414 -0
  112. rocket_engine/execution/console/__init__.py +1 -0
  113. rocket_engine/execution/console/apply_session.py +318 -0
  114. rocket_engine/execution/console/lifecycle.py +265 -0
  115. rocket_engine/execution/dispatch.py +205 -0
  116. rocket_engine/execution/errors.py +31 -0
  117. rocket_engine/execution/ports.py +797 -0
  118. rocket_engine/execution/pyexec/__init__.py +1 -0
  119. rocket_engine/execution/pyexec/apply_context.py +547 -0
  120. rocket_engine/execution/pyexec/approvals.py +173 -0
  121. rocket_engine/execution/pyexec/runner.py +404 -0
  122. rocket_engine/execution/pyexec/support.py +464 -0
  123. rocket_engine/execution/python_exec.py +897 -0
  124. rocket_engine/execution/queue.py +85 -0
  125. rocket_engine/execution/replay_safe.py +601 -0
  126. rocket_engine/execution/routing/__init__.py +1 -0
  127. rocket_engine/execution/routing/calls.py +364 -0
  128. rocket_engine/execution/routing/namespace.py +785 -0
  129. rocket_engine/execution/routing/ports.py +102 -0
  130. rocket_engine/execution/routing/tools.py +346 -0
  131. rocket_engine/execution/runner.py +311 -0
  132. rocket_engine/execution/runtime.py +1149 -0
  133. rocket_engine/execution/service.py +279 -0
  134. rocket_engine/execution/worker.py +464 -0
  135. rocket_engine/interfaces/README.md +26 -0
  136. rocket_engine/interfaces/__init__.py +58 -0
  137. rocket_engine/interfaces/apig_assets.py +407 -0
  138. rocket_engine/interfaces/celllink.py +461 -0
  139. rocket_engine/interfaces/documents.py +92 -0
  140. rocket_engine/interfaces/envelopes.py +131 -0
  141. rocket_engine/interfaces/errors.py +16 -0
  142. rocket_engine/interfaces/layout.py +410 -0
  143. rocket_engine/interfaces/layout_schema.yaml +278 -0
  144. rocket_engine/interfaces/paths.py +223 -0
  145. rocket_engine/interfaces/pdks.py +550 -0
  146. rocket_engine/interfaces/ports.py +72 -0
  147. rocket_engine/interfaces/references.py +115 -0
  148. rocket_engine/interfaces/refs.py +85 -0
  149. rocket_engine/interfaces/runparams.py +304 -0
  150. rocket_engine/interfaces/setup.py +552 -0
  151. rocket_engine/interfaces/setup_catalogue.yaml +243 -0
  152. rocket_engine/interfaces/setup_probes.py +66 -0
  153. rocket_engine/interfaces/vocabulary.py +58 -0
  154. rocket_engine/interfaces/yaml_dump.py +31 -0
  155. rocket_engine/library/README.md +22 -0
  156. rocket_engine/library/__init__.py +13 -0
  157. rocket_engine/library/apig_catalog.py +371 -0
  158. rocket_engine/library/catalog.py +185 -0
  159. rocket_engine/library/errors.py +16 -0
  160. rocket_engine/mutate/__init__.py +776 -0
  161. rocket_engine/mutate/operators.py +700 -0
  162. rocket_engine/ops/__init__.py +1 -0
  163. rocket_engine/ops/deferred.py +279 -0
  164. rocket_engine/ops/editors.py +404 -0
  165. rocket_engine/ops/evidence.py +194 -0
  166. rocket_engine/ops/gates.py +262 -0
  167. rocket_engine/ops/ports.py +76 -0
  168. rocket_engine/ops/queries.py +417 -0
  169. rocket_engine/ops/runs.py +553 -0
  170. rocket_engine/ops/submit.py +614 -0
  171. rocket_engine/project/README.md +27 -0
  172. rocket_engine/project/__init__.py +6 -0
  173. rocket_engine/project/canvas/__init__.py +55 -0
  174. rocket_engine/project/canvas/compile.py +109 -0
  175. rocket_engine/project/canvas/integrate.py +48 -0
  176. rocket_engine/project/canvas/schema.py +241 -0
  177. rocket_engine/project/canvas/templates.py +46 -0
  178. rocket_engine/project/canvas/validate.py +603 -0
  179. rocket_engine/project/edit/__init__.py +5 -0
  180. rocket_engine/project/edit/cas.py +87 -0
  181. rocket_engine/project/edit/transactions.py +282 -0
  182. rocket_engine/project/errors.py +16 -0
  183. rocket_engine/project/frontend13.py +685 -0
  184. rocket_engine/project/geometry/__init__.py +23 -0
  185. rocket_engine/project/geometry/ops.py +451 -0
  186. rocket_engine/project/geometry/snap.py +103 -0
  187. rocket_engine/project/ip/__init__.py +1 -0
  188. rocket_engine/project/ip/blocks.py +195 -0
  189. rocket_engine/project/ip/entry.schema.json +78 -0
  190. rocket_engine/project/ip/lifecycle.py +402 -0
  191. rocket_engine/project/ip/verbs.py +123 -0
  192. rocket_engine/project/layers/__init__.py +6 -0
  193. rocket_engine/project/layers/bindings.py +115 -0
  194. rocket_engine/project/layers/catalog.py +228 -0
  195. rocket_engine/project/layers/presets.py +43 -0
  196. rocket_engine/project/ledger/__init__.py +1 -0
  197. rocket_engine/project/ledger/docs.py +167 -0
  198. rocket_engine/project/ledger/entities.py +176 -0
  199. rocket_engine/project/ledger/events.py +29 -0
  200. rocket_engine/project/ledger/runs.py +257 -0
  201. rocket_engine/project/ledger/shared.py +174 -0
  202. rocket_engine/project/ledger/snapshots.py +385 -0
  203. rocket_engine/project/ledger/wires.py +552 -0
  204. rocket_engine/project/process/__init__.py +14 -0
  205. rocket_engine/project/process/model.py +193 -0
  206. rocket_engine/project/process/recipes.py +121 -0
  207. rocket_engine/project/schematic/__init__.py +1 -0
  208. rocket_engine/project/schematic/hierarchy.py +312 -0
  209. rocket_engine/project/schematic/instances.py +393 -0
  210. rocket_engine/project/schematic/sheets.py +198 -0
  211. rocket_engine/project/service.py +2941 -0
  212. rocket_engine/project/symbols/__init__.py +1 -0
  213. rocket_engine/project/symbols/cells.py +324 -0
  214. rocket_engine/project/symbols/linkage.py +356 -0
  215. rocket_engine/project/symbols/symbols.py +352 -0
  216. rocket_engine/project/templates/__init__.py +303 -0
  217. rocket_engine/project/templates/errors.py +25 -0
  218. rocket_engine/project/testbench/__init__.py +49 -0
  219. rocket_engine/project/testbench/blocks.py +619 -0
  220. rocket_engine/project/testbench/templates.py +134 -0
  221. rocket_engine/stores/README.md +12 -0
  222. rocket_engine/stores/__init__.py +6 -0
  223. rocket_engine/stores/ecs.py +1508 -0
  224. rocket_engine/stores/layers.py +1023 -0
  225. rocket_engine/stores/settings.py +1188 -0
  226. rocket_engine/stores/wireplace.py +1324 -0
  227. rocket_engine/workflows/README.md +18 -0
  228. rocket_engine/workflows/__init__.py +53 -0
  229. rocket_engine/workflows/capability_plan.py +102 -0
  230. rocket_engine/workflows/completion.py +228 -0
  231. rocket_engine/workflows/connectgraph.py +734 -0
  232. rocket_engine/workflows/connectgraph_emit.py +336 -0
  233. rocket_engine/workflows/gates.py +144 -0
  234. rocket_engine/workflows/photonic_wire.py +76 -0
  235. rocket_engine/workflows/stages.py +324 -0
  236. rocket_engine/workflows/viewsync.py +399 -0
  237. rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
  238. rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
  239. rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
  240. rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
  241. rocket_engine-0.0.2b1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,794 @@
1
+ """Connectable MCP stdio server: initialize/tools/list/tools/call.
2
+
3
+ One tool per cataloged operation/query; handlers route through
4
+ McpTransport so validation stays single-sourced. Tool descriptions
5
+ and input schemas read from the serving handler docstrings via the
6
+ api_spec generator — the same text the generated reference serves
7
+ (ADR-003 double-use). build_server() is transport-free; only main()
8
+ touches stdio.
9
+
10
+ Bound to a project (`--project`), the server fills project_id and the
11
+ current expected_revision itself; submit runs inline (same run_id ack
12
+ shape plus the verdict), run_job stays for deferred/batch pumping
13
+ plus idempotent fetch, and run_spice targets come from the
14
+ project's constraints.yaml (`--project-dir`): the agent never sets
15
+ them. The same file names the PDK (`pdk:`); every run_spice the bound
16
+ server admits carries that pin. Finished runs are recorded under
17
+ results/engine-runs for IDE and agent alike.
18
+ """
19
+
20
+ import argparse
21
+ import asyncio
22
+ import json
23
+ import math
24
+ import shutil
25
+ import subprocess
26
+ import uuid
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+
30
+ from mcp.server import Server
31
+ from mcp.server.stdio import stdio_server
32
+ from mcp.types import CallToolResult, ListToolsResult, TextContent, Tool
33
+ from ruamel.yaml import YAML
34
+
35
+ from ...execution.errors import ExecutionError
36
+ from ...execution.python_exec import MCP_INPUT_SCHEMA
37
+ from ...interfaces.errors import ContractError
38
+ from ...interfaces.paths import slugify
39
+ from ...interfaces.pdks import list_pdks, resolve_engine_root
40
+ from ...interfaces.vocabulary import OPERATIONS, QUERIES
41
+ from ...project.errors import ProjectError
42
+ from ..errors import AdapterError
43
+ from . import McpTransport
44
+
45
+ _ENGINE_ERRORS = (AdapterError, ProjectError, ExecutionError)
46
+
47
+ TRUST_NOTE = (
48
+ "Local-only trust: this MCP server speaks stdio to the local "
49
+ "harness under OS identity, with no auth step because there is "
50
+ "no second party — no tokens, no broker, no listener, no remote "
51
+ "execution, no multi-user. Human Run on the displayed cell is the "
52
+ "approval; the harness edge (rocket-fuel client permissions) owns "
53
+ "ask/deny, never this adapter. localhost-only, no auth, same "
54
+ "caller-blind API as every other transport (ADR-006 applies to "
55
+ "the HTTP adapter; stdio needs no boundary because it cannot "
56
+ "leave the machine)."
57
+ )
58
+
59
+
60
+ # Grandfathered override: python_execute keeps its richer hand
61
+ # validation schema (types, bounds, mode oneOf) until API-V1-CLIENTS
62
+ # retires this wire. Every other tool derives its schema from the
63
+ # api_spec generator — never add a second entry here.
64
+ _TOOL_SCHEMAS = {
65
+ "python_execute": MCP_INPUT_SCHEMA,
66
+ }
67
+
68
+
69
+ #: Arguments that are lists. The generated schemas carry only descriptions, so
70
+ #: a model can't tell, and often sends them as text.
71
+ _LIST_ARGUMENTS = ("sources", "analyses", "measurements")
72
+
73
+
74
+ _API_SCHEMAS = None
75
+
76
+
77
+ _HELP_ENGINE = None
78
+
79
+
80
+ def _help_engine():
81
+ """Docs-only Engine for solver introspection (never submits).
82
+
83
+ Returns:
84
+ Shared in-memory Engine whose solver map describes each
85
+ operation's serving adapters.
86
+ """
87
+ global _HELP_ENGINE
88
+ if _HELP_ENGINE is None:
89
+ from ...composition import Engine
90
+
91
+ _HELP_ENGINE = Engine("mcp-help", actor="mcp")
92
+ return _HELP_ENGINE
93
+
94
+
95
+ def _summary_of(target):
96
+ """First docstring line of one handler (help-text source).
97
+
98
+ Args:
99
+ target: Handler class, method, or function to summarize.
100
+
101
+ Returns:
102
+ Summary line, or "" when the handler is undocumented.
103
+ """
104
+ doc = target.__doc__ or ""
105
+ return doc.splitlines()[0].strip() if doc.splitlines() else ""
106
+
107
+
108
+ def describe_tool(name):
109
+ """Describe one tool from its serving handler docstring.
110
+
111
+ Wired solver operations read the primary adapter's class
112
+ summary; CRUD operations and wired queries read their
113
+ ProjectService method summary; Engine-served queries read
114
+ their Engine method summary; run_job reads the phase-2
115
+ runner summary; unwired names honestly say so. The generated
116
+ reference reads the same docstrings, so docs and agent help
117
+ cannot drift apart.
118
+
119
+ Args:
120
+ name: Cataloged operation or query name.
121
+
122
+ Returns:
123
+ Tool description text for the name.
124
+ """
125
+ from ...composition import (
126
+ ENGINE_OP_METHODS,
127
+ QUERY_ENGINE_METHODS,
128
+ QUERY_METHODS,
129
+ Engine,
130
+ )
131
+ from ...project.service import ProjectService
132
+
133
+ solvers = _help_engine().solvers_for(name)
134
+ if solvers:
135
+ return _summary_of(next(iter(solvers.values())))
136
+ op_method = ENGINE_OP_METHODS.get(name)
137
+ if op_method is not None:
138
+ return _summary_of(getattr(Engine, op_method))
139
+ if name == "run_job":
140
+ # run_job's contract doc lives on the phase-2 runner
141
+ # (introspection only; never called for help).
142
+ return _summary_of(Engine._run_queued)
143
+ if name == "python_execute":
144
+ # python_execute's contract doc lives on the approval
145
+ # request (introspection only; never called for help).
146
+ from ...execution.python_exec import PythonExecService
147
+
148
+ return _summary_of(PythonExecService.request_execution)
149
+ if name in OPERATIONS and hasattr(ProjectService, name):
150
+ return _summary_of(getattr(ProjectService, name))
151
+ method = QUERY_METHODS.get(name)
152
+ if method is not None:
153
+ return _summary_of(getattr(ProjectService, method))
154
+ emethod = QUERY_ENGINE_METHODS.get(name)
155
+ if emethod is not None:
156
+ return _summary_of(getattr(Engine, emethod))
157
+ kind = "operation" if name in OPERATIONS else "query"
158
+ return f"Declared {kind} `{name}`; no handler wired yet."
159
+
160
+
161
+ def _engine_root():
162
+ """Repo root holding docs/ for the generated-schema fallback.
163
+
164
+ Returns:
165
+ Path three levels above this file
166
+ (src/adapters/mcp/server.py).
167
+ """
168
+ return Path(__file__).resolve().parents[3]
169
+
170
+
171
+ def _load_generated_schemas():
172
+ """Load the api_spec generator output (package data or repo docs).
173
+
174
+ The wheel ships docs/api-schema.json as adapter package data;
175
+ a repo checkout falls back to docs/api-schema.json on disk.
176
+
177
+ Returns:
178
+ {"operations": {...}, "queries": {...}} mapping, or {} when
179
+ neither source is readable.
180
+ """
181
+ try:
182
+ from importlib.resources import files
183
+
184
+ text = (files(__package__) / "api-schema.json").read_text(
185
+ encoding="utf-8"
186
+ )
187
+ return json.loads(text)
188
+ except Exception:
189
+ pass
190
+ try:
191
+ return json.loads(
192
+ (_engine_root() / "docs" / "api-schema.json").read_text(
193
+ encoding="utf-8"
194
+ )
195
+ )
196
+ except Exception:
197
+ return {}
198
+
199
+
200
+ def _generated_schemas():
201
+ """Per-op schemas from the api_spec generator output, cached.
202
+
203
+ Returns:
204
+ {"operations": {...}, "queries": {...}} mapping, or {} when
205
+ no generator output is readable.
206
+ """
207
+ global _API_SCHEMAS
208
+ if _API_SCHEMAS is None:
209
+ _API_SCHEMAS = _load_generated_schemas()
210
+ return _API_SCHEMAS
211
+
212
+
213
+ def input_schema_for(name):
214
+ """Wire inputSchema for one cataloged tool, from the generator.
215
+
216
+ Wired tools carry their generated properties/required verbatim;
217
+ unwired names keep the generic object with a loud marker naming
218
+ the tool. python_execute keeps its richer hand validation
219
+ schema (pinned by test_python_exec_wiring.py) until
220
+ API-V1-CLIENTS retires this wire.
221
+
222
+ Args:
223
+ name: Cataloged operation or query name.
224
+
225
+ Returns:
226
+ JSON Schema dict for the tool's inputSchema.
227
+ """
228
+ if name in _TOOL_SCHEMAS:
229
+ return _TOOL_SCHEMAS[name]
230
+ if name in OPERATIONS:
231
+ section = "operations"
232
+ elif name in QUERIES:
233
+ section = "queries"
234
+ else:
235
+ section = None
236
+ entry = None
237
+ if section is not None:
238
+ entry = _generated_schemas().get(section, {}).get(name)
239
+ if entry and entry.get("wired"):
240
+ return {
241
+ "type": "object",
242
+ "properties": entry.get("properties", {}),
243
+ "required": entry.get("required", []),
244
+ }
245
+ kind = "operation" if name in OPERATIONS else "query"
246
+ return {
247
+ "type": "object",
248
+ "description": (
249
+ f"No generated schema for {kind} `{name}` yet — no "
250
+ "handler wired; see docs/engine-api.md."
251
+ ),
252
+ }
253
+
254
+
255
+ def tool_definitions():
256
+ """Build one MCP tool per cataloged operation/query.
257
+
258
+ Descriptions come from the serving handler docstrings and input
259
+ schemas come from the api_spec generator over those same
260
+ docstrings — the same text the generated reference serves
261
+ (ADR-003 double-use). A new documented op gains its wire schema
262
+ with no server edit.
263
+
264
+ Returns:
265
+ Tool list with per-operation descriptions and generated
266
+ input schemas, sorted by name.
267
+ """
268
+ tools = []
269
+ for name in sorted(OPERATIONS | QUERIES):
270
+ tools.append(Tool(
271
+ name=name,
272
+ description=describe_tool(name),
273
+ inputSchema=input_schema_for(name),
274
+ ))
275
+ return tools
276
+
277
+
278
+ def _decode_list_arguments(arguments):
279
+ """Accept a list argument sent as JSON text or as one bare string.
280
+
281
+ Models often send lists this way, and iterating the string would read
282
+ it one character at a time.
283
+
284
+ Args:
285
+ arguments: Tool call arguments.
286
+
287
+ Returns:
288
+ The arguments with each known list argument as a list.
289
+ """
290
+ decoded = dict(arguments)
291
+ for key in _LIST_ARGUMENTS:
292
+ value = decoded.get(key)
293
+ if not isinstance(value, str):
294
+ continue
295
+ try:
296
+ parsed = json.loads(value)
297
+ except ValueError:
298
+ parsed = value
299
+ decoded[key] = parsed if isinstance(parsed, list) else [parsed]
300
+ return decoded
301
+
302
+
303
+ def _error(code, message, fields=None):
304
+ payload = {"ok": False, "code": code, "message": message}
305
+ if fields:
306
+ payload["fields"] = list(fields)
307
+ return CallToolResult(
308
+ content=[TextContent(
309
+ type="text",
310
+ text=json.dumps(payload),
311
+ )],
312
+ isError=True,
313
+ )
314
+
315
+
316
+ #: Host-execution opt-in surface (I19): there is none yet.
317
+ #:
318
+ #: The MCP wire exposes cataloged engine operations and read
319
+ #: queries only; host shells first among them are rejected, never
320
+ #: routed (McpTransport.host_execution_enabled stays False). Any
321
+ #: future host-execution tool MUST arrive behind an explicit,
322
+ #: named opt-in surface (engine flag plus project scope) — never
323
+ #: as a silent tool. Until that decision lands, this constant pins
324
+ #: the default-deny posture against drift.
325
+ HOST_EXECUTION_OPT_IN = None
326
+
327
+
328
+ def _confine_files(arguments, project_dir):
329
+ """Resolve run_spice sources inside the project and fix the workdir.
330
+
331
+ Thin delegate over the engine-side confinement every transport
332
+ shares (Engine.confine_spice_arguments): identical sources,
333
+ identical workdir, identical refusal shapes on every transport.
334
+ """
335
+ from ...composition import Engine
336
+
337
+ confined = Engine.confine_spice_arguments(arguments, project_dir)
338
+ arguments.clear()
339
+ arguments.update(confined)
340
+
341
+
342
+ def _source_state(root):
343
+ """(short commit, dirty) of the git working tree, or (None, None)."""
344
+ git = ["git", "-C", str(root)]
345
+ head = subprocess.run([*git, "rev-parse", "--short", "HEAD"], capture_output=True, text=True)
346
+ if head.returncode != 0:
347
+ return None, None
348
+ status = subprocess.run(
349
+ [*git, "status", "--porcelain", "--", "design", "tests", "constraints.yaml"],
350
+ capture_output=True, text=True,
351
+ )
352
+ return head.stdout.strip(), bool(status.stdout.strip())
353
+
354
+
355
+ def _finite(value):
356
+ """Replace NaN and infinities with None so a reply is valid JSON.
357
+
358
+ A perfect optical null is -inf dB; JSON has no infinity and browsers
359
+ reject Python's `-Infinity`, so it travels as null.
360
+
361
+ Args:
362
+ value: Any JSON-shaped value.
363
+
364
+ Returns:
365
+ The same value with every non-finite float replaced by None.
366
+ """
367
+ if isinstance(value, float) and not math.isfinite(value):
368
+ return None
369
+ if isinstance(value, dict):
370
+ return {key: _finite(item) for key, item in value.items()}
371
+ if isinstance(value, list):
372
+ return [_finite(item) for item in value]
373
+ return value
374
+
375
+
376
+ def _keep_datasets(root, folder, engine, run_id):
377
+ """Keep a run's waveforms and spectra next to its record.
378
+
379
+ Args:
380
+ root: Resolved project folder.
381
+ folder: Evidence folder inside the project.
382
+ engine: Engine the run executed on.
383
+ run_id: Run whose datasets to keep.
384
+
385
+ Returns:
386
+ List of {analysis, path} mappings relative to the project.
387
+ """
388
+ kept = []
389
+ # Under the run id, so the next run can't overwrite them. A second fetch
390
+ # of the run finds them already moved.
391
+ for dataset in engine.datasets(run_id) if hasattr(engine, "datasets") else []:
392
+ target = folder / f"{run_id}-{dataset['analysis']}.raw"
393
+ source = Path(dataset["path"])
394
+ if source.is_file():
395
+ shutil.move(str(source), target)
396
+ if target.is_file():
397
+ kept.append({"analysis": dataset["analysis"], "path": str(target.relative_to(root))})
398
+ spectra = engine.spectra(run_id) if hasattr(engine, "spectra") else []
399
+ if spectra:
400
+ target = folder / f"{run_id}-optical.json"
401
+ target.write_text(json.dumps(_finite(spectra), allow_nan=False), encoding="utf-8")
402
+ kept.append({"analysis": "optical", "path": str(target.relative_to(root))})
403
+ return kept
404
+
405
+
406
+ def write_evidence(project_dir, engine, outcomes, arguments):
407
+ """Persist finished runs under results/engine-runs/<run_id>.json.
408
+
409
+ Waveform datasets (ngspice ASCII rawfiles) are kept as
410
+ <run_id>-<analysis>.raw, optical spectra as <run_id>-optical.json.
411
+
412
+ Args:
413
+ project_dir: Project folder the evidence is written into.
414
+ engine: Engine the runs executed on (for the native log).
415
+ outcomes: Completion mappings with run_id, operation, verdict.
416
+ arguments: Submitted arguments (for the run's source list).
417
+
418
+ Note:
419
+ Only the engine runs jobs; this only records them, so IDE
420
+ buttons and agent calls share one evidence trail.
421
+ """
422
+ root = Path(project_dir).resolve()
423
+ folder = root / "results" / "engine-runs"
424
+ folder.mkdir(parents=True, exist_ok=True)
425
+ commit, dirty = _source_state(root)
426
+ finished = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
427
+ for outcome in outcomes:
428
+ record = {
429
+ "producer": "rocket-engine",
430
+ "run_id": outcome["run_id"],
431
+ "operation": outcome["operation"],
432
+ "verdict": outcome["verdict"],
433
+ "finished_at": finished,
434
+ "source_commit": commit,
435
+ "source_dirty": dirty,
436
+ "sources": [str(Path(s).relative_to(root)) if Path(s).is_relative_to(root) else s
437
+ for s in arguments.get("sources") or []],
438
+ "log": engine.log_text(outcome["run_id"]) if hasattr(engine, "log_text") else "",
439
+ "datasets": _keep_datasets(root, folder, engine, outcome["run_id"]),
440
+ }
441
+ (folder / f"{outcome['run_id']}.json").write_text(
442
+ json.dumps(_finite(record), indent=2, allow_nan=False), encoding="utf-8"
443
+ )
444
+
445
+
446
+ def make_handlers(engine_factory, project_scope=None, project_dir=None):
447
+ """Build transport-free list/call handlers over McpTransport.
448
+
449
+ Handlers route through McpTransport so validation stays
450
+ single-sourced; engines are cached per project id. Submit runs
451
+ inline (ack plus verdict); run_job stays the deferred/batch
452
+ pump and idempotent fetch.
453
+
454
+ Args:
455
+ engine_factory: Callable turning a project id into an
456
+ engine.
457
+ project_scope: Optional scope enforced on every call; fills
458
+ project_id and the current expected_revision.
459
+ project_dir: Optional project folder; run_spice sources are
460
+ confined inside it, targets and the PDK pin load from
461
+ its constraints.yaml, and finished runs are recorded
462
+ under its results/engine-runs.
463
+
464
+ Returns:
465
+ (list_tools, call_tool) async handler pair. Call failures
466
+ become error results, never exceptions.
467
+ """
468
+ engines = {}
469
+ submitted_arguments = {}
470
+
471
+ def _engine_for(project_id):
472
+ if project_id not in engines:
473
+ engines[project_id] = engine_factory(project_id)
474
+ return engines[project_id]
475
+
476
+ async def _list_tools(ctx, params):
477
+ return ListToolsResult(tools=tool_definitions())
478
+
479
+ async def _call_tool(ctx, params):
480
+ arguments = _decode_list_arguments(params.arguments or {})
481
+ if project_scope is not None:
482
+ arguments.setdefault("project_id", project_scope)
483
+ project_id = arguments.get("project_id")
484
+ if project_id is None:
485
+ return _error("invalid_command",
486
+ "Missing project_id in arguments.")
487
+ try:
488
+ engine = _engine_for(project_id)
489
+ transport = McpTransport(engine, project_id=project_scope)
490
+ if (project_scope is not None and params.name in OPERATIONS
491
+ and params.name != "run_job"
492
+ and "expected_revision" not in arguments
493
+ and hasattr(engine, "snapshot")):
494
+ arguments["expected_revision"] = engine.snapshot()["current_revision"]
495
+ if project_dir is not None and params.name == "run_spice":
496
+ _confine_files(arguments, project_dir)
497
+ # Read per call so a constraints.yaml edit applies without a restart.
498
+ pin = load_pdk_pin(project_dir)
499
+ if pin is not None:
500
+ arguments["pdk"] = pin
501
+ result = transport.handle(json.dumps({
502
+ "method": "tools/call",
503
+ "params": {"name": params.name, "arguments": arguments},
504
+ "id": uuid.uuid4().hex,
505
+ }))
506
+ if (
507
+ project_dir is not None
508
+ and params.name in OPERATIONS
509
+ and params.name != "run_job"
510
+ and isinstance(result, dict)
511
+ and "run_id" in result
512
+ ):
513
+ submitted_arguments[result["run_id"]] = arguments
514
+ # Recorded as soon as a run finishes: submit runs inline, and a
515
+ # model that never fetches it must not leave the IDE with no record.
516
+ if (
517
+ project_dir is not None
518
+ and params.name.startswith("run_")
519
+ and isinstance(result, dict)
520
+ and result.get("state") == "completed"
521
+ and "verdict" in result
522
+ and "run_id" in result
523
+ ):
524
+ write_evidence(
525
+ project_dir, engine, [result],
526
+ submitted_arguments.get(result["run_id"], {}),
527
+ )
528
+ except _ENGINE_ERRORS as exc:
529
+ return _error(exc.code, str(exc),
530
+ getattr(exc, "fields", None))
531
+ return CallToolResult(content=[TextContent(
532
+ type="text", text=json.dumps(_finite(result), allow_nan=False))])
533
+
534
+ return _list_tools, _call_tool
535
+
536
+
537
+ def build_server(engine_factory, project_scope=None, project_dir=None):
538
+ """Build the MCP server with list/call handlers attached.
539
+
540
+ Args:
541
+ engine_factory: Callable turning a project id into an
542
+ engine.
543
+ project_scope: Optional scope enforced on every call.
544
+ project_dir: Optional project folder for source
545
+ confinement and run evidence.
546
+
547
+ Returns:
548
+ Configured MCP server (stdio wiring stays in main).
549
+ """
550
+ list_handler, call_handler = make_handlers(engine_factory,
551
+ project_scope, project_dir)
552
+ return Server("rocket-engine",
553
+ on_list_tools=list_handler,
554
+ on_call_tool=call_handler)
555
+
556
+
557
+ def load_constraints(project_dir):
558
+ """Read run_spice targets from the project's constraints.yaml.
559
+
560
+ Args:
561
+ project_dir: Project folder holding constraints.yaml.
562
+
563
+ Returns:
564
+ {metric: {min|max, unit[, expr, control]}} mapping (`>=`
565
+ is min, `<=` is max, `==` carries no bound), or None when
566
+ the file is missing.
567
+ """
568
+ path = Path(project_dir) / "constraints.yaml"
569
+ if not path.is_file():
570
+ return None
571
+ data = YAML(typ="safe").load(path.read_text(encoding="utf-8")) or {}
572
+ targets = {}
573
+ for check in data.get("checks", []):
574
+ bound = {">=": "min", "<=": "max"}.get(check.get("comparison"))
575
+ if bound and check.get("metric"):
576
+ target = targets.setdefault(check["metric"], {"unit": check.get("unit", "")})
577
+ target[bound] = check["value"]
578
+ # Optional: how the metric is measured (ngspice expr plus control lines such as meas).
579
+ if check.get("expr"):
580
+ target["expr"] = check["expr"]
581
+ if check.get("control"):
582
+ target["control"] = list(check["control"])
583
+ return targets
584
+
585
+
586
+ def load_pdk_pin(project_dir):
587
+ """Resolve the project's PDK pin from its constraints.yaml.
588
+
589
+ `pdk: <id>` pins the one revision registered under that id;
590
+ `pdk: {id, revision}` is taken as written (the solver checks it
591
+ against the store when it runs). `none`, an empty value or no
592
+ `pdk` key means generic models.
593
+
594
+ Args:
595
+ project_dir: Project folder holding constraints.yaml.
596
+
597
+ Returns:
598
+ {"id", "revision"} pin for run_spice, or None when the
599
+ project names no PDK or has no constraints.yaml.
600
+
601
+ Raises:
602
+ AdapterError: unknown_pdk when the id is not registered on
603
+ this machine; invalid_command for a malformed value or
604
+ an id with several revisions; a store error's own code
605
+ when the store cannot be read.
606
+
607
+ Caution:
608
+ Never falls back to generic models when a named PDK cannot
609
+ be pinned: a silent fallback would score the design against
610
+ the wrong devices.
611
+ """
612
+ path = Path(project_dir) / "constraints.yaml"
613
+ if not path.is_file():
614
+ return None
615
+ data = YAML(typ="safe").load(path.read_text(encoding="utf-8")) or {}
616
+ value = data.get("pdk")
617
+ if value is None or (isinstance(value, str) and value.strip().lower() in ("", "none")):
618
+ return None
619
+ if isinstance(value, dict):
620
+ pin = {key: value.get(key) for key in ("id", "revision")}
621
+ if not all(isinstance(part, str) and part for part in pin.values()):
622
+ raise AdapterError(
623
+ "invalid_command", f"constraints.yaml pdk needs id and revision: {value!r}."
624
+ )
625
+ return pin
626
+ if not isinstance(value, str):
627
+ raise AdapterError(
628
+ "invalid_command",
629
+ f"constraints.yaml pdk must be an id or {{id, revision}}: {value!r}.",
630
+ )
631
+ name = value.strip()
632
+ try:
633
+ slug = slugify(name)
634
+ registered = list_pdks() if resolve_engine_root().is_dir() else []
635
+ except ContractError as exc:
636
+ raise AdapterError(exc.code, str(exc)) from exc
637
+ matches = [entry for entry in registered if slugify(entry["id"]) == slug]
638
+ if len(matches) == 1:
639
+ return {"id": matches[0]["id"], "revision": matches[0]["revision"]}
640
+ if matches:
641
+ revisions = ", ".join(entry["revision"] for entry in matches)
642
+ raise AdapterError(
643
+ "invalid_command",
644
+ f"PDK {name} has several revisions ({revisions}); pin one in "
645
+ f"constraints.yaml as pdk: {{id: {name}, revision: <revision>}}.",
646
+ )
647
+ known = ", ".join(f"{entry['id']} {entry['revision']}" for entry in registered) or "none"
648
+ raise AdapterError(
649
+ "unknown_pdk",
650
+ f"PDK {name} is not registered with Rocket Engine on this machine "
651
+ f"(registered: {known}). Register it with scripts/install.sh --pdk-only.",
652
+ )
653
+
654
+
655
+ def _call_once(engine_factory, args):
656
+ from mcp.types import CallToolRequestParams
657
+
658
+ created = {}
659
+
660
+ def factory(pid):
661
+ if pid not in created:
662
+ created[pid] = engine_factory(pid)
663
+ return created[pid]
664
+
665
+ _, call = make_handlers(factory, args.project, args.project_dir)
666
+
667
+ def invoke(name, arguments):
668
+ result = asyncio.run(call(None, CallToolRequestParams(name=name, arguments=arguments)))
669
+ return result.is_error, json.loads(result.content[0].text)
670
+
671
+ failed, body = invoke(args.call, json.loads(args.args))
672
+ if (
673
+ not failed
674
+ and args.call in OPERATIONS
675
+ and "run_id" in body
676
+ and "verdict" not in body
677
+ ):
678
+ failed, body = invoke("run_job", {"run_id": body["run_id"]})
679
+ if not failed and "run_id" in body:
680
+ failed, body = invoke("get_job_status", {"run_id": body["run_id"]})
681
+ if (
682
+ not failed
683
+ and args.call in OPERATIONS
684
+ and args.project_dir is not None
685
+ and "run_id" in body
686
+ and body.get("state") == "completed"
687
+ ):
688
+ engine = created.get(args.project)
689
+ if engine is not None:
690
+ write_evidence(args.project_dir, engine, [body], json.loads(args.args))
691
+ print(json.dumps(body))
692
+ return 1 if failed else 0
693
+
694
+
695
+ def main(argv=None):
696
+ """Serve the MCP stdio server until stdin closes.
697
+
698
+ Args:
699
+ argv: CLI vector: --project binds the project id (fills
700
+ project_id/expected_revision), --project-dir confines
701
+ run_spice sources and records evidence,
702
+ --call/--args run one operation through the same
703
+ handlers, then its run_job and status, and exit.
704
+
705
+ Returns:
706
+ Process exit code (0 on a clean stdio shutdown, or a
707
+ successful one-shot call).
708
+ """
709
+ from ...composition import Engine
710
+
711
+ parser = argparse.ArgumentParser(prog="rocket-engine-mcp")
712
+ parser.add_argument(
713
+ "--project", help="bind to this project id (fills project_id/expected_revision)"
714
+ )
715
+ parser.add_argument(
716
+ "--project-dir",
717
+ help="project folder; run_spice targets and PDK pin come from its constraints.yaml",
718
+ )
719
+ parser.add_argument(
720
+ "--call", help="run one operation through the same handlers, print its job status, exit"
721
+ )
722
+ parser.add_argument("--args", default="{}", help="JSON arguments for --call")
723
+ args = parser.parse_args(argv)
724
+ constraints = load_constraints(args.project_dir) if args.project_dir else None
725
+ engine_factory = lambda pid: Engine(pid, actor="mcp", constraints=constraints) # noqa: E731
726
+
727
+ if args.call:
728
+ return _call_once(engine_factory, args)
729
+
730
+ server = build_server(
731
+ engine_factory,
732
+ project_scope=args.project,
733
+ project_dir=args.project_dir,
734
+ )
735
+
736
+ async def _run():
737
+ async with stdio_server() as (read, write):
738
+ await server.run(
739
+ read, write, server.create_initialization_options())
740
+
741
+ asyncio.run(_run())
742
+ return 0
743
+
744
+
745
+ if __name__ == "__main__":
746
+ raise SystemExit(main())
747
+
748
+
749
+ #: Lifecycle kinds that ride the progress channel instead of a
750
+ #: plain message notification.
751
+ _PROGRESS_KINDS = frozenset({"job_progress"})
752
+
753
+
754
+ def build_lifecycle_notification(event):
755
+ """Build one MCP push notification for a lifecycle event.
756
+
757
+ Append-only PUSH-EVENTS seam: existing list/call handlers
758
+ are untouched — this only translates the runtime event log
759
+ into JSON-RPC notifications agents can subscribe to.
760
+
761
+ Args:
762
+ event: Lifecycle event with seq, kind, and run_id.
763
+
764
+ Returns:
765
+ JSON-RPC notification with jsonrpc, method
766
+ (notifications/progress for progress, else
767
+ notifications/<kind>), and the event as params.
768
+ """
769
+ kind = event.get("kind", "message")
770
+ if kind in _PROGRESS_KINDS:
771
+ method = "notifications/progress"
772
+ else:
773
+ method = f"notifications/{kind}"
774
+ return {"jsonrpc": "2.0", "method": method,
775
+ "params": dict(event)}
776
+
777
+
778
+ def subscribe_runtime(runtime, send):
779
+ """Push every lifecycle event to one subscriber.
780
+
781
+ Append-only PUSH-EVENTS seam: registers a listener on the
782
+ runtime; the list/call path keeps serving polling clients
783
+ unchanged.
784
+
785
+ Args:
786
+ runtime: EngineRuntime emitting lifecycle events.
787
+ send: Called synchronously with each built
788
+ notification mapping.
789
+
790
+ Returns:
791
+ Unsubscribe callable removing the listener.
792
+ """
793
+ return runtime.add_listener(
794
+ lambda event: send(build_lifecycle_notification(event)))