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.
- rocket_engine/__init__.py +8 -0
- rocket_engine/__main__.py +12 -0
- rocket_engine/adapters/README.md +54 -0
- rocket_engine/adapters/__init__.py +7 -0
- rocket_engine/adapters/api.py +39 -0
- rocket_engine/adapters/base.py +46 -0
- rocket_engine/adapters/cace/__init__.py +163 -0
- rocket_engine/adapters/circulax/README.md +10 -0
- rocket_engine/adapters/circulax/__init__.py +112 -0
- rocket_engine/adapters/circulax/_driver.py +190 -0
- rocket_engine/adapters/circulax/adapter.py +538 -0
- rocket_engine/adapters/circulax/results.py +291 -0
- rocket_engine/adapters/cli/README.md +34 -0
- rocket_engine/adapters/cli/__init__.py +305 -0
- rocket_engine/adapters/connectivity/__init__.py +196 -0
- rocket_engine/adapters/connectivity/results.py +214 -0
- rocket_engine/adapters/cosim/README.md +9 -0
- rocket_engine/adapters/cosim/__init__.py +1390 -0
- rocket_engine/adapters/cosim/results.py +214 -0
- rocket_engine/adapters/devsim/__init__.py +171 -0
- rocket_engine/adapters/digital_flow/README.md +9 -0
- rocket_engine/adapters/digital_flow/__init__.py +1121 -0
- rocket_engine/adapters/digital_flow/results.py +227 -0
- rocket_engine/adapters/digital_flow/state.py +462 -0
- rocket_engine/adapters/drc/README.klayout.md +7 -0
- rocket_engine/adapters/drc/README.md +7 -0
- rocket_engine/adapters/drc/__init__.py +1 -0
- rocket_engine/adapters/drc/klayout.py +952 -0
- rocket_engine/adapters/drc/magic.py +287 -0
- rocket_engine/adapters/drc/results.py +184 -0
- rocket_engine/adapters/drc/results_kl.py +188 -0
- rocket_engine/adapters/elmer/__init__.py +163 -0
- rocket_engine/adapters/envelope.py +120 -0
- rocket_engine/adapters/equivalence/__init__.py +156 -0
- rocket_engine/adapters/errors.py +181 -0
- rocket_engine/adapters/extract_magic/README.md +15 -0
- rocket_engine/adapters/extract_magic/__init__.py +425 -0
- rocket_engine/adapters/extract_magic/results.py +209 -0
- rocket_engine/adapters/formal_sby/README.md +7 -0
- rocket_engine/adapters/formal_sby/__init__.py +773 -0
- rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
- rocket_engine/adapters/formal_sby/results.py +185 -0
- rocket_engine/adapters/gdsfactory/__init__.py +194 -0
- rocket_engine/adapters/gdsfactory/_driver.py +459 -0
- rocket_engine/adapters/gdsfactory/adapter.py +609 -0
- rocket_engine/adapters/gdsfactory/results.py +181 -0
- rocket_engine/adapters/hdl_sim/README.md +7 -0
- rocket_engine/adapters/hdl_sim/__init__.py +369 -0
- rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
- rocket_engine/adapters/hdl_sim/results.py +178 -0
- rocket_engine/adapters/hdl_sim/verilator.py +167 -0
- rocket_engine/adapters/http/README.md +15 -0
- rocket_engine/adapters/http/__init__.py +458 -0
- rocket_engine/adapters/http/events.py +63 -0
- rocket_engine/adapters/ipc/README.md +9 -0
- rocket_engine/adapters/ipc/__init__.py +111 -0
- rocket_engine/adapters/lvs_netgen/README.md +20 -0
- rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
- rocket_engine/adapters/lvs_netgen/results.py +168 -0
- rocket_engine/adapters/mcp/README.md +19 -0
- rocket_engine/adapters/mcp/__init__.py +85 -0
- rocket_engine/adapters/mcp/api-schema.json +6107 -0
- rocket_engine/adapters/mcp/pull.py +246 -0
- rocket_engine/adapters/mcp/server.py +794 -0
- rocket_engine/adapters/meep/__init__.py +167 -0
- rocket_engine/adapters/meow/README.md +7 -0
- rocket_engine/adapters/meow/__init__.py +190 -0
- rocket_engine/adapters/meow/_driver.py +227 -0
- rocket_engine/adapters/meow/_envelope.py +108 -0
- rocket_engine/adapters/meow/adapter.py +491 -0
- rocket_engine/adapters/meow/results.py +218 -0
- rocket_engine/adapters/mutation/__init__.py +192 -0
- rocket_engine/adapters/ngspice/README.md +28 -0
- rocket_engine/adapters/ngspice/__init__.py +627 -0
- rocket_engine/adapters/ngspice/results.py +215 -0
- rocket_engine/adapters/postlayout/README.md +28 -0
- rocket_engine/adapters/postlayout/__init__.py +17 -0
- rocket_engine/adapters/postlayout/adapter.py +1062 -0
- rocket_engine/adapters/postlayout/results.py +330 -0
- rocket_engine/adapters/postlayout/translate.py +71 -0
- rocket_engine/adapters/profile.py +220 -0
- rocket_engine/adapters/sax/README.md +23 -0
- rocket_engine/adapters/sax/__init__.py +516 -0
- rocket_engine/adapters/sax/_driver.py +148 -0
- rocket_engine/adapters/sax/results.py +291 -0
- rocket_engine/adapters/stdio_console.py +1151 -0
- rocket_engine/adapters/syntax/README.md +5 -0
- rocket_engine/adapters/syntax/__init__.py +366 -0
- rocket_engine/adapters/syntax/results.py +181 -0
- rocket_engine/adapters/synth_yosys/README.md +7 -0
- rocket_engine/adapters/synth_yosys/__init__.py +655 -0
- rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
- rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
- rocket_engine/adapters/synth_yosys/results.py +173 -0
- rocket_engine/adapters/timing_sta/README.md +9 -0
- rocket_engine/adapters/timing_sta/__init__.py +726 -0
- rocket_engine/adapters/view_sync/__init__.py +185 -0
- rocket_engine/adapters/view_sync/results.py +189 -0
- rocket_engine/adapters/vlsir/__init__.py +142 -0
- rocket_engine/adapters/xschem/__init__.py +440 -0
- rocket_engine/adapters/xschem/deck.py +266 -0
- rocket_engine/clients/__init__.py +5 -0
- rocket_engine/clients/runtime.py +255 -0
- rocket_engine/composition.py +2279 -0
- rocket_engine/execution/README.md +30 -0
- rocket_engine/execution/__init__.py +150 -0
- rocket_engine/execution/apig/__init__.py +1 -0
- rocket_engine/execution/apig/pool.py +463 -0
- rocket_engine/execution/apig/submit.py +124 -0
- rocket_engine/execution/block_runs_scope.py +479 -0
- rocket_engine/execution/coalesce.py +414 -0
- rocket_engine/execution/console/__init__.py +1 -0
- rocket_engine/execution/console/apply_session.py +318 -0
- rocket_engine/execution/console/lifecycle.py +265 -0
- rocket_engine/execution/dispatch.py +205 -0
- rocket_engine/execution/errors.py +31 -0
- rocket_engine/execution/ports.py +797 -0
- rocket_engine/execution/pyexec/__init__.py +1 -0
- rocket_engine/execution/pyexec/apply_context.py +547 -0
- rocket_engine/execution/pyexec/approvals.py +173 -0
- rocket_engine/execution/pyexec/runner.py +404 -0
- rocket_engine/execution/pyexec/support.py +464 -0
- rocket_engine/execution/python_exec.py +897 -0
- rocket_engine/execution/queue.py +85 -0
- rocket_engine/execution/replay_safe.py +601 -0
- rocket_engine/execution/routing/__init__.py +1 -0
- rocket_engine/execution/routing/calls.py +364 -0
- rocket_engine/execution/routing/namespace.py +785 -0
- rocket_engine/execution/routing/ports.py +102 -0
- rocket_engine/execution/routing/tools.py +346 -0
- rocket_engine/execution/runner.py +311 -0
- rocket_engine/execution/runtime.py +1149 -0
- rocket_engine/execution/service.py +279 -0
- rocket_engine/execution/worker.py +464 -0
- rocket_engine/interfaces/README.md +26 -0
- rocket_engine/interfaces/__init__.py +58 -0
- rocket_engine/interfaces/apig_assets.py +407 -0
- rocket_engine/interfaces/celllink.py +461 -0
- rocket_engine/interfaces/documents.py +92 -0
- rocket_engine/interfaces/envelopes.py +131 -0
- rocket_engine/interfaces/errors.py +16 -0
- rocket_engine/interfaces/layout.py +410 -0
- rocket_engine/interfaces/layout_schema.yaml +278 -0
- rocket_engine/interfaces/paths.py +223 -0
- rocket_engine/interfaces/pdks.py +550 -0
- rocket_engine/interfaces/ports.py +72 -0
- rocket_engine/interfaces/references.py +115 -0
- rocket_engine/interfaces/refs.py +85 -0
- rocket_engine/interfaces/runparams.py +304 -0
- rocket_engine/interfaces/setup.py +552 -0
- rocket_engine/interfaces/setup_catalogue.yaml +243 -0
- rocket_engine/interfaces/setup_probes.py +66 -0
- rocket_engine/interfaces/vocabulary.py +58 -0
- rocket_engine/interfaces/yaml_dump.py +31 -0
- rocket_engine/library/README.md +22 -0
- rocket_engine/library/__init__.py +13 -0
- rocket_engine/library/apig_catalog.py +371 -0
- rocket_engine/library/catalog.py +185 -0
- rocket_engine/library/errors.py +16 -0
- rocket_engine/mutate/__init__.py +776 -0
- rocket_engine/mutate/operators.py +700 -0
- rocket_engine/ops/__init__.py +1 -0
- rocket_engine/ops/deferred.py +279 -0
- rocket_engine/ops/editors.py +404 -0
- rocket_engine/ops/evidence.py +194 -0
- rocket_engine/ops/gates.py +262 -0
- rocket_engine/ops/ports.py +76 -0
- rocket_engine/ops/queries.py +417 -0
- rocket_engine/ops/runs.py +553 -0
- rocket_engine/ops/submit.py +614 -0
- rocket_engine/project/README.md +27 -0
- rocket_engine/project/__init__.py +6 -0
- rocket_engine/project/canvas/__init__.py +55 -0
- rocket_engine/project/canvas/compile.py +109 -0
- rocket_engine/project/canvas/integrate.py +48 -0
- rocket_engine/project/canvas/schema.py +241 -0
- rocket_engine/project/canvas/templates.py +46 -0
- rocket_engine/project/canvas/validate.py +603 -0
- rocket_engine/project/edit/__init__.py +5 -0
- rocket_engine/project/edit/cas.py +87 -0
- rocket_engine/project/edit/transactions.py +282 -0
- rocket_engine/project/errors.py +16 -0
- rocket_engine/project/frontend13.py +685 -0
- rocket_engine/project/geometry/__init__.py +23 -0
- rocket_engine/project/geometry/ops.py +451 -0
- rocket_engine/project/geometry/snap.py +103 -0
- rocket_engine/project/ip/__init__.py +1 -0
- rocket_engine/project/ip/blocks.py +195 -0
- rocket_engine/project/ip/entry.schema.json +78 -0
- rocket_engine/project/ip/lifecycle.py +402 -0
- rocket_engine/project/ip/verbs.py +123 -0
- rocket_engine/project/layers/__init__.py +6 -0
- rocket_engine/project/layers/bindings.py +115 -0
- rocket_engine/project/layers/catalog.py +228 -0
- rocket_engine/project/layers/presets.py +43 -0
- rocket_engine/project/ledger/__init__.py +1 -0
- rocket_engine/project/ledger/docs.py +167 -0
- rocket_engine/project/ledger/entities.py +176 -0
- rocket_engine/project/ledger/events.py +29 -0
- rocket_engine/project/ledger/runs.py +257 -0
- rocket_engine/project/ledger/shared.py +174 -0
- rocket_engine/project/ledger/snapshots.py +385 -0
- rocket_engine/project/ledger/wires.py +552 -0
- rocket_engine/project/process/__init__.py +14 -0
- rocket_engine/project/process/model.py +193 -0
- rocket_engine/project/process/recipes.py +121 -0
- rocket_engine/project/schematic/__init__.py +1 -0
- rocket_engine/project/schematic/hierarchy.py +312 -0
- rocket_engine/project/schematic/instances.py +393 -0
- rocket_engine/project/schematic/sheets.py +198 -0
- rocket_engine/project/service.py +2941 -0
- rocket_engine/project/symbols/__init__.py +1 -0
- rocket_engine/project/symbols/cells.py +324 -0
- rocket_engine/project/symbols/linkage.py +356 -0
- rocket_engine/project/symbols/symbols.py +352 -0
- rocket_engine/project/templates/__init__.py +303 -0
- rocket_engine/project/templates/errors.py +25 -0
- rocket_engine/project/testbench/__init__.py +49 -0
- rocket_engine/project/testbench/blocks.py +619 -0
- rocket_engine/project/testbench/templates.py +134 -0
- rocket_engine/stores/README.md +12 -0
- rocket_engine/stores/__init__.py +6 -0
- rocket_engine/stores/ecs.py +1508 -0
- rocket_engine/stores/layers.py +1023 -0
- rocket_engine/stores/settings.py +1188 -0
- rocket_engine/stores/wireplace.py +1324 -0
- rocket_engine/workflows/README.md +18 -0
- rocket_engine/workflows/__init__.py +53 -0
- rocket_engine/workflows/capability_plan.py +102 -0
- rocket_engine/workflows/completion.py +228 -0
- rocket_engine/workflows/connectgraph.py +734 -0
- rocket_engine/workflows/connectgraph_emit.py +336 -0
- rocket_engine/workflows/gates.py +144 -0
- rocket_engine/workflows/photonic_wire.py +76 -0
- rocket_engine/workflows/stages.py +324 -0
- rocket_engine/workflows/viewsync.py +399 -0
- rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
- rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
- rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
- rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
- 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)))
|