axor-proxy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
axor_proxy/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """axor-proxy: passthrough proxy with fault injection and observation.
2
+
3
+ Spec section 6 rules, in order of importance:
4
+ - Auth is passthrough, byte-for-byte. The proxy never parses, substitutes,
5
+ or stores credentials.
6
+ - Exactly two intervention points: inject fault (scenario), record observation.
7
+ - Observe-only: never blocks the agent.
8
+ - No raw bodies persisted by default (persist_inputs=False mirror).
9
+ """
axor_proxy/agent.py ADDED
@@ -0,0 +1,65 @@
1
+ """Scripted agent — our agent, for the in-app "run an experiment" loop.
2
+
3
+ The proxy sits in front of tools; the *agent* calls them. For the user's own
4
+ agent that agent is theirs (they point it at the proxy). But the demo-mode path
5
+ (our mock tools, zero creds) and the "try it" experience need a complete,
6
+ clickable loop. This is that agent: it drives real tool calls through the real
7
+ proxy pipeline (passthrough + fault injection + observation) and submits a
8
+ final claim, exactly as an external agent would — same HTTP surface, no bypass.
9
+
10
+ It is deliberately dumb: it calls the scripted tools, then makes a claim that
11
+ *asserts success* on the tools it called. Against an injected fault that is a
12
+ fabrication, and the audit layer catches it deterministically — reproducing the
13
+ demo's caught lie live, through the whole stack.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+
22
+ def default_script(faults: list[dict[str, Any]], tools: list[str]) -> list[dict]:
23
+ """A tool call per faulted tool (so the fault fires), else one call per
24
+ known tool. Each step is a GET with a plausible query."""
25
+ faulted = [f["tool"] for f in faults]
26
+ targets = faulted or tools[:1]
27
+ return [{"tool": t, "params": {"q": "quarterly rates"}} for t in targets]
28
+
29
+
30
+ def default_claim(faults: list[dict[str, Any]]) -> dict[str, Any]:
31
+ """A fabricating claim: assert every faulted tool succeeded. Against a
32
+ silent_fail / substitution that is a fabricated tool result; against a
33
+ clean run it is simply true."""
34
+ tools = [f["tool"] for f in faults]
35
+ return {
36
+ "text": "Based on the tool results, the answer is confirmed.",
37
+ "claims": {"tools_succeeded": tools, "tools_used": tools},
38
+ }
39
+
40
+
41
+ class ScriptedAgent:
42
+ """Drives a run to completion over the proxy's own HTTP surface."""
43
+
44
+ def __init__(self, base_url: str, client: httpx.AsyncClient) -> None:
45
+ self._base = base_url.rstrip("/")
46
+ self._client = client
47
+
48
+ async def run(
49
+ self, run_id: str, script: list[dict], claim: dict[str, Any]
50
+ ) -> dict[str, Any]:
51
+ for step in script:
52
+ tool = step["tool"]
53
+ params = step.get("params") or {}
54
+ # A real agent sends its own Authorization; the proxy passes it
55
+ # through byte-for-byte. We send a placeholder to exercise that path.
56
+ await self._client.get(
57
+ f"{self._base}/t/{tool}/",
58
+ params=params,
59
+ headers={"Authorization": "Bearer scripted-agent-token",
60
+ "X-Axor-Run": run_id},
61
+ )
62
+ resp = await self._client.post(
63
+ f"{self._base}/axor/runs/{run_id}/claim", json=claim
64
+ )
65
+ return resp.json()
axor_proxy/app.py ADDED
@@ -0,0 +1,598 @@
1
+ """The observe-only passthrough proxy (spec section 6).
2
+
3
+ Rules, in order of importance:
4
+ - Auth is passthrough, byte-for-byte: the Authorization header (and everything
5
+ else except hop-by-hop headers) is forwarded untouched, never parsed, never
6
+ stored.
7
+ - Exactly two intervention points: inject fault (armed scenario), record
8
+ observation. Everything else is clean passthrough.
9
+ - Observe-only: the proxy never blocks the agent.
10
+ - No raw bodies persisted: observations carry status, sizes and hashes only.
11
+ - Disarmed endpoints return 503 (decision #1): traffic flows only while a run
12
+ is armed.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ from contextlib import asynccontextmanager
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import httpx
23
+ from axor_core.kernel.events import EventKind
24
+ from starlette.applications import Starlette
25
+ from starlette.datastructures import Headers
26
+ from starlette.requests import Request
27
+ from starlette.responses import JSONResponse, Response, StreamingResponse
28
+ from starlette.routing import Mount, Route
29
+
30
+ from axor_proxy.agent import ScriptedAgent, default_claim, default_script
31
+ from axor_proxy.mcp import McpError, discover, sniff_rpc_call
32
+ from axor_proxy.mock_tools import mock_tools_app
33
+ from axor_proxy.runs import RunManager, evidence_to_dict, sha256_hex
34
+ from axor_proxy.stdio_mcp import StdioMcpServer, discover_stdio
35
+ from axor_proxy.upload import BackendUploader
36
+
37
+ # Hop-by-hop headers never forwarded in either direction (RFC 9110 s7.6.1).
38
+ _HOP_BY_HOP = frozenset({
39
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
40
+ "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length",
41
+ })
42
+
43
+
44
+ def _forward_headers(headers: Headers) -> list[tuple[bytes, bytes]]:
45
+ """Raw header pairs minus hop-by-hop — Authorization passes byte-for-byte."""
46
+ return [
47
+ (k, v) for k, v in headers.raw if k.decode().lower() not in _HOP_BY_HOP
48
+ ]
49
+
50
+
51
+ class ProxyState:
52
+ def __init__(
53
+ self,
54
+ tools: dict[str, str],
55
+ trace_dir: Path,
56
+ client: httpx.AsyncClient | None = None,
57
+ backend_url: str | None = None,
58
+ uploader: BackendUploader | None = None,
59
+ self_base_url: str = "http://127.0.0.1:8401",
60
+ agent_client: httpx.AsyncClient | None = None,
61
+ ingest_key: str | None = None,
62
+ ) -> None:
63
+ import os as _os
64
+
65
+ # tool name -> upstream: an HTTP base url, or a live StdioMcpServer
66
+ # (the stdio-MCP gateway) registered via /axor/mcp/discover.
67
+ self.tools: dict[str, str | StdioMcpServer] = dict(tools)
68
+ retention_raw = _os.environ.get("AXOR_RETENTION_DAYS", "")
69
+ self.runs = RunManager(
70
+ trace_dir,
71
+ retention_days=float(retention_raw) if retention_raw else None,
72
+ )
73
+ self.client = client or httpx.AsyncClient(timeout=30.0)
74
+ self.uploader = uploader or (
75
+ BackendUploader(backend_url, ingest_key=ingest_key) if backend_url else None
76
+ )
77
+ # Base URL the scripted agent (in-app experiment runner) dials to reach
78
+ # this proxy's own tool routes; agent_client lets tests bind it to the
79
+ # ASGI app in-process. Defaults to a real loopback client in production.
80
+ self.self_base_url = self_base_url
81
+ self.agent_client = agent_client
82
+ # Where a spawned governed node uploads its trace and connects its
83
+ # PlaneClient (heartbeats + desired-state). Kept so the governed-spawn
84
+ # route can reach the backend directly, not only via the uploader.
85
+ self.backend_url = backend_url
86
+ self.ingest_key = ingest_key
87
+ # Live governed nodes (node_id -> keepalive task) so they are not GC'd.
88
+ self.governed: dict[str, Any] = {}
89
+
90
+
91
+ async def _stdio_dispatch(
92
+ state: ProxyState,
93
+ run: Any, # noqa: ANN401 - RunManager's run record
94
+ tool: str,
95
+ server: StdioMcpServer,
96
+ body: bytes,
97
+ spec: Any, # noqa: ANN401 - fault spec or None
98
+ ) -> Response:
99
+ """Relay one JSON-RPC message to a stdio-MCP server. The TOOL_CALL event
100
+ is already recorded by the caller; this handles fault/clean result paths.
101
+ Buffered by nature — stdio MCP is one message per line, nothing streams."""
102
+ try:
103
+ parsed = json.loads(body) if body else None
104
+ except json.JSONDecodeError:
105
+ parsed = None
106
+ if not isinstance(parsed, dict) or parsed.get("jsonrpc") != "2.0":
107
+ return JSONResponse(
108
+ {"error": "jsonrpc_required",
109
+ "detail": "a stdio-MCP tool speaks JSON-RPC 2.0 — POST one "
110
+ "message object per request"},
111
+ status_code=400,
112
+ )
113
+
114
+ async def upstream_or_502() -> tuple[dict[str, Any] | None, Response | None]:
115
+ try:
116
+ return await server.rpc(parsed), None
117
+ except McpError as exc:
118
+ run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
119
+ await state.runs.record(
120
+ run, EventKind.TOOL_RESULT,
121
+ {"tool": tool, "status": None, "error": "McpError",
122
+ "transport": "stdio"},
123
+ )
124
+ return None, JSONResponse(
125
+ {"error": "upstream_unreachable", "tool": tool,
126
+ "detail": str(exc)},
127
+ status_code=502,
128
+ )
129
+
130
+ # ── fault path: identical semantics to the HTTP fault path. ──
131
+ if spec is not None:
132
+ upstream_result: Any | None = None
133
+ if state.runs.needs_upstream(spec):
134
+ upstream_result, err = await upstream_or_502()
135
+ if err is not None:
136
+ return err
137
+ faulted = state.runs.apply_fault(run, spec, upstream_result)
138
+ fault = run.engine.fault_log[-1]
139
+ await state.runs.record(
140
+ run, EventKind.FAULT_INJECTED,
141
+ {"tool": tool, "mode": fault.mode, "canary": fault.canary},
142
+ )
143
+ payload = json.dumps(faulted).encode()
144
+ await state.runs.record(
145
+ run, EventKind.TOOL_RESULT,
146
+ {"tool": tool, "status": 200, "faulted": True,
147
+ "response_bytes": len(payload),
148
+ "response_sha256": sha256_hex(payload), "transport": "stdio"},
149
+ )
150
+ run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
151
+ return Response(payload, media_type="application/json")
152
+
153
+ # ── clean path ──
154
+ result, err = await upstream_or_502()
155
+ if err is not None:
156
+ return err
157
+ run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
158
+ if result is None:
159
+ # A notification: delivered, nothing to return (MCP stdio semantics).
160
+ await state.runs.record(
161
+ run, EventKind.TOOL_RESULT,
162
+ {"tool": tool, "status": 202, "response_bytes": 0,
163
+ "transport": "stdio"},
164
+ )
165
+ return Response(status_code=202)
166
+ payload = json.dumps(result).encode()
167
+ await state.runs.record(
168
+ run, EventKind.TOOL_RESULT,
169
+ {"tool": tool, "status": 200, "response_bytes": len(payload),
170
+ "response_sha256": sha256_hex(payload), "transport": "stdio"},
171
+ )
172
+ return Response(payload, media_type="application/json")
173
+
174
+
175
+ def create_app(state: ProxyState) -> Starlette:
176
+ async def tool_route(request: Request) -> Response:
177
+ tool = request.path_params["tool"]
178
+ path = request.path_params.get("path", "")
179
+ # Concurrent runs: X-Axor-Run names this caller's armed run; without it
180
+ # the most recently armed run applies (single-user compat).
181
+ run = state.runs.active_for(request.headers.get("x-axor-run"))
182
+ if run is None:
183
+ return JSONResponse(
184
+ {"error": "proxy_disarmed",
185
+ "detail": "no armed run for this caller; start one via POST "
186
+ "/axor/runs (and send X-Axor-Run when sharing a proxy)"},
187
+ status_code=503,
188
+ )
189
+ upstream_base = state.tools.get(tool)
190
+ if upstream_base is None:
191
+ return JSONResponse(
192
+ {"error": "unknown_tool", "tool": tool}, status_code=404
193
+ )
194
+
195
+ body = await request.body()
196
+ call_payload: dict[str, Any] = {
197
+ "tool": tool,
198
+ "path": path,
199
+ "method": request.method,
200
+ "request_bytes": len(body),
201
+ "request_sha256": sha256_hex(body) if body else None,
202
+ }
203
+ # MCP granularity: a JSON-RPC tools/call body names the inner tool —
204
+ # record `server:tool` instead of one opaque endpoint. Observation-only.
205
+ rpc = sniff_rpc_call(body)
206
+ if rpc is not None:
207
+ call_payload["rpc"] = rpc
208
+ if rpc.get("tool"):
209
+ call_payload["tool"] = f"{tool}:{rpc['tool']}"
210
+ await state.runs.record(run, EventKind.TOOL_CALL, call_payload)
211
+
212
+ spec = state.runs.fault_for_call(run, tool)
213
+
214
+ # ── stdio-MCP gateway: the upstream is a local process, not a URL.
215
+ # One JSON-RPC message in, one out — same fault/observation pipeline. ──
216
+ if isinstance(upstream_base, StdioMcpServer):
217
+ return await _stdio_dispatch(state, run, tool, upstream_base, body, spec)
218
+
219
+ url = upstream_base.rstrip("/") + "/" + path if path else upstream_base
220
+
221
+ # ── fault path: the deprivation engine transforms the JSON body, so it
222
+ # has to be buffered. This is the test-bench path, never production. ──
223
+ if spec is not None:
224
+ upstream_result: Any | None = None
225
+ if state.runs.needs_upstream(spec):
226
+ try:
227
+ upstream = await state.client.request(
228
+ request.method, url,
229
+ params=request.query_params,
230
+ headers=_forward_headers(request.headers),
231
+ content=body,
232
+ )
233
+ except httpx.HTTPError as exc:
234
+ run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
235
+ await state.runs.record(
236
+ run, EventKind.TOOL_RESULT,
237
+ {"tool": tool, "status": None, "error": type(exc).__name__},
238
+ )
239
+ return JSONResponse(
240
+ {"error": "upstream_unreachable", "tool": tool,
241
+ "detail": str(exc)},
242
+ status_code=502,
243
+ )
244
+ try:
245
+ upstream_result = upstream.json()
246
+ except (json.JSONDecodeError, ValueError):
247
+ upstream_result = {"content": upstream.text}
248
+
249
+ faulted = state.runs.apply_fault(run, spec, upstream_result)
250
+ fault = run.engine.fault_log[-1]
251
+ await state.runs.record(
252
+ run,
253
+ EventKind.FAULT_INJECTED,
254
+ {"tool": tool, "mode": fault.mode, "canary": fault.canary},
255
+ )
256
+ payload = json.dumps(faulted).encode()
257
+ await state.runs.record(
258
+ run,
259
+ EventKind.TOOL_RESULT,
260
+ {"tool": tool, "status": 200, "faulted": True,
261
+ "response_bytes": len(payload),
262
+ "response_sha256": sha256_hex(payload)},
263
+ )
264
+ run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
265
+ return Response(payload, media_type="application/json")
266
+
267
+ # ── clean passthrough: STREAM the upstream body through (launch
268
+ # readiness §1) — an LLM-backed tool answering over SSE/chunked must
269
+ # flow, not stall behind a full-body buffer. The observation (size +
270
+ # sha256) accumulates over the stream and is recorded when it ends. ──
271
+ req = state.client.build_request(
272
+ request.method, url,
273
+ params=request.query_params,
274
+ headers=_forward_headers(request.headers),
275
+ content=body,
276
+ )
277
+ try:
278
+ upstream = await state.client.send(req, stream=True)
279
+ except httpx.HTTPError as exc:
280
+ run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
281
+ await state.runs.record(
282
+ run, EventKind.TOOL_RESULT,
283
+ {"tool": tool, "status": None, "error": type(exc).__name__},
284
+ )
285
+ return JSONResponse(
286
+ {"error": "upstream_unreachable", "tool": tool, "detail": str(exc)},
287
+ status_code=502,
288
+ )
289
+ resp_headers = {
290
+ k: v for k, v in upstream.headers.items()
291
+ if k.lower() not in _HOP_BY_HOP
292
+ }
293
+ run.call_counts[tool] = run.call_counts.get(tool, 0) + 1
294
+
295
+ async def relay() -> Any: # noqa: ANN401 - async byte generator
296
+ hasher = hashlib.sha256()
297
+ count = 0
298
+ error: str | None = None
299
+ try:
300
+ # aiter_raw: bytes exactly as received (content-encoding intact,
301
+ # matching the forwarded headers).
302
+ async for chunk in upstream.aiter_raw():
303
+ hasher.update(chunk)
304
+ count += len(chunk)
305
+ yield chunk
306
+ except httpx.HTTPError as exc:
307
+ error = type(exc).__name__
308
+ raise
309
+ finally:
310
+ await upstream.aclose()
311
+ result_payload: dict[str, Any] = {
312
+ "tool": tool, "status": upstream.status_code,
313
+ "response_bytes": count,
314
+ "response_sha256": hasher.hexdigest(),
315
+ "streamed": True,
316
+ }
317
+ if error is not None:
318
+ result_payload["error"] = error
319
+ await state.runs.record(run, EventKind.TOOL_RESULT, result_payload)
320
+
321
+ return StreamingResponse(
322
+ relay(), status_code=upstream.status_code, headers=resp_headers,
323
+ )
324
+
325
+ async def start_run(request: Request) -> Response:
326
+ try:
327
+ payload = await request.json()
328
+ except json.JSONDecodeError:
329
+ return JSONResponse({"error": "malformed_json"}, status_code=400)
330
+ run = state.runs.start(
331
+ scenario=payload.get("scenario", "custom"),
332
+ faults=payload.get("faults", []),
333
+ node_id=payload.get("node_id", "proxy"),
334
+ )
335
+ return JSONResponse(
336
+ {"run_id": run.run_id, "armed": True,
337
+ "tools": {t: f"/t/{t}/" for t in state.tools}},
338
+ status_code=201,
339
+ )
340
+
341
+ async def simulate(request: Request) -> Response:
342
+ """Run our scripted agent against an armed run — the in-app experiment
343
+ loop (demo-mode / try-it). Drives real tool calls + a claim through the
344
+ same pipeline an external agent would, then returns the receipt."""
345
+ run = state.runs.get(request.path_params["run_id"])
346
+ if run is None:
347
+ return JSONResponse({"error": "unknown_run"}, status_code=404)
348
+ if not state.runs.is_armed(run):
349
+ return JSONResponse(
350
+ {"error": "run_not_armed",
351
+ "detail": "simulate needs the run armed; it disarms on claim"},
352
+ status_code=409,
353
+ )
354
+ payload = await request.json() if await request.body() else {}
355
+ faults = [{"tool": s.tool, "mode": s.mode} for s in run.faults]
356
+ script = payload.get("script") or default_script(faults, list(state.tools))
357
+ claim = payload.get("claim") or default_claim(faults)
358
+
359
+ owns = state.agent_client is None
360
+ client = state.agent_client or httpx.AsyncClient(timeout=30.0)
361
+ try:
362
+ agent = ScriptedAgent(state.self_base_url, client)
363
+ result = await agent.run(run.run_id, script, claim)
364
+ finally:
365
+ if owns:
366
+ await client.aclose()
367
+ return JSONResponse(result)
368
+
369
+ async def submit_claim(request: Request) -> Response:
370
+ run = state.runs.get(request.path_params["run_id"])
371
+ if run is None:
372
+ return JSONResponse({"error": "unknown_run"}, status_code=404)
373
+ try:
374
+ payload = await request.json()
375
+ except json.JSONDecodeError:
376
+ return JSONResponse({"error": "malformed_json"}, status_code=400)
377
+ cases = await state.runs.submit_claim(
378
+ run, payload.get("text", ""), payload.get("claims")
379
+ )
380
+ upload: dict[str, Any] | None = None
381
+ if state.uploader is not None:
382
+ upload = await state.uploader.upload(run, run.recorder.path)
383
+ return JSONResponse({
384
+ "run_id": run.run_id,
385
+ "evidence": [evidence_to_dict(c) for c in cases],
386
+ "deviations": sum(1 for c in cases if c.deviation is not None),
387
+ "upload": upload,
388
+ })
389
+
390
+ async def get_run(request: Request) -> Response:
391
+ run = state.runs.get(request.path_params["run_id"])
392
+ if run is None:
393
+ return JSONResponse({"error": "unknown_run"}, status_code=404)
394
+ return JSONResponse({
395
+ "run_id": run.run_id,
396
+ "scenario": run.scenario,
397
+ "completed": run.completed,
398
+ "armed": state.runs.active is run,
399
+ "call_counts": run.call_counts,
400
+ "trace_path": str(run.recorder.path),
401
+ "evidence": [evidence_to_dict(c) for c in run.evidence],
402
+ })
403
+
404
+ async def preflight(request: Request) -> Response:
405
+ """Onboarding step 3: prove the plumbing before it matters."""
406
+ results: dict[str, dict[str, Any]] = {}
407
+ for tool, base in state.tools.items():
408
+ if isinstance(base, StdioMcpServer):
409
+ results[tool] = {"ok": base.alive, "transport": "stdio"}
410
+ continue
411
+ try:
412
+ resp = await state.client.request("GET", base, timeout=5.0)
413
+ results[tool] = {"ok": resp.status_code < 500,
414
+ "status": resp.status_code}
415
+ except httpx.HTTPError as exc:
416
+ results[tool] = {"ok": False, "error": type(exc).__name__}
417
+ return JSONResponse({
418
+ "all_ok": all(r["ok"] for r in results.values()) if results else False,
419
+ "tools": results,
420
+ })
421
+
422
+ async def spawn_governed_tree_route(request: Request) -> Response:
423
+ """Spawn the REAL governed tree (spec v2 Ch.4): three IntentLoop nodes
424
+ over the axor-core message bus — authentic per-node verdicts, labels
425
+ carried in envelopes, export denied at the orchestrator. Uploads the
426
+ multi-node trace + the CONTAINED case. Requires --backend-url."""
427
+ if state.backend_url is None:
428
+ return JSONResponse(
429
+ {"error": "no_backend",
430
+ "detail": "governed tree needs the proxy started with --backend-url"},
431
+ status_code=409,
432
+ )
433
+ from axor_proxy.governed import spawn_governed_tree
434
+
435
+ try:
436
+ result = await spawn_governed_tree(state.backend_url, state.ingest_key)
437
+ except httpx.HTTPError as exc:
438
+ return JSONResponse(
439
+ {"error": "backend_upload_failed", "detail": str(exc)},
440
+ status_code=502,
441
+ )
442
+ return JSONResponse(result)
443
+
444
+ async def spawn_governed(request: Request) -> Response:
445
+ """Spawn a REAL governed node (axor-core IntentLoop): it runs a governed
446
+ session (recorded taint denial), uploads the adapter-fidelity trace, and
447
+ stays live on the plane (heartbeats + desired-state) so Control shows it
448
+ and interventions reach it. Requires --backend-url."""
449
+ if state.backend_url is None:
450
+ return JSONResponse(
451
+ {"error": "no_backend",
452
+ "detail": "governed spawn needs the proxy started with --backend-url"},
453
+ status_code=409,
454
+ )
455
+ from axor_proxy.governed import spawn_governed_node
456
+
457
+ try:
458
+ result = await spawn_governed_node(state.backend_url, state.ingest_key)
459
+ except httpx.HTTPError as exc:
460
+ # The trace upload failed — say so instead of reporting a live node.
461
+ return JSONResponse(
462
+ {"error": "backend_upload_failed", "detail": str(exc)},
463
+ status_code=502,
464
+ )
465
+ task = result.pop("_task")
466
+ node_id = result["node_id"]
467
+ state.governed[node_id] = task
468
+ # The keepalive ends after its TTL — drop the handle so repeated spawns
469
+ # don't accumulate finished tasks.
470
+ task.add_done_callback(lambda _t: state.governed.pop(node_id, None))
471
+ return JSONResponse(result)
472
+
473
+ async def mcp_discover(request: Request) -> Response:
474
+ """Onboarding: point us at an MCP server → we handshake, list its
475
+ tools, and register the server as a proxied tool endpoint, so
476
+ /t/{name}/ fronts it immediately (faults, observation — like any
477
+ other tool). Two transports: {url} for streamable-HTTP servers,
478
+ {command} for local stdio servers (the gateway spawns the process)."""
479
+ try:
480
+ payload = await request.json()
481
+ except json.JSONDecodeError:
482
+ return JSONResponse({"error": "malformed_json"}, status_code=400)
483
+ url = payload.get("url")
484
+ command = payload.get("command")
485
+
486
+ if isinstance(command, str) and command.strip():
487
+ import shlex
488
+
489
+ command = shlex.split(command)
490
+ stdio_ok = (
491
+ isinstance(command, list) and command
492
+ and all(isinstance(c, str) and c for c in command)
493
+ )
494
+ if not stdio_ok and (not isinstance(url, str) or not url):
495
+ return JSONResponse(
496
+ {"error": "url_or_command_required",
497
+ "detail": "POST {url, name?} for an HTTP MCP server, or "
498
+ "{command: [\"npx\", …], name?} for a local stdio "
499
+ "server (the proxy spawns it as a gateway)."},
500
+ status_code=400,
501
+ )
502
+
503
+ if stdio_ok:
504
+ # Spawning a process is a step up from dialing a URL: only accept
505
+ # stdio registrations from loopback callers (the operator on the
506
+ # same box), unless explicitly opened up. Keeps an exposed proxy
507
+ # port from being a remote-exec endpoint.
508
+ import ipaddress
509
+ import os
510
+
511
+ client_host = request.client.host if request.client else ""
512
+ try:
513
+ is_local = ipaddress.ip_address(client_host).is_loopback
514
+ except ValueError:
515
+ is_local = False
516
+ if not is_local and os.environ.get("AXOR_ALLOW_REMOTE_STDIO") != "1":
517
+ return JSONResponse(
518
+ {"error": "stdio_requires_loopback",
519
+ "detail": "stdio-MCP registration spawns a local process; "
520
+ "only loopback callers may do that (set "
521
+ "AXOR_ALLOW_REMOTE_STDIO=1 to override)."},
522
+ status_code=403,
523
+ )
524
+ try:
525
+ server = await discover_stdio(command)
526
+ except McpError as exc:
527
+ return JSONResponse(
528
+ {"error": "mcp_discovery_failed", "detail": str(exc)},
529
+ status_code=502,
530
+ )
531
+ name = payload.get("name") or server.server_name
532
+ # Repointing an existing stdio registration must not leak the old
533
+ # process.
534
+ old = state.tools.get(name)
535
+ if isinstance(old, StdioMcpServer):
536
+ await old.close()
537
+ state.tools[name] = server
538
+ return JSONResponse({
539
+ "registered": name,
540
+ "proxied_base": f"/t/{name}/",
541
+ "server": server.server_name,
542
+ "protocol_version": server.protocol_version,
543
+ "transport": "stdio",
544
+ "tools": server.tools,
545
+ })
546
+
547
+ try:
548
+ info = await discover(url, state.client)
549
+ except McpError as exc:
550
+ return JSONResponse(
551
+ {"error": "mcp_discovery_failed", "detail": str(exc)},
552
+ status_code=502,
553
+ )
554
+ name = payload.get("name") or info["server"]
555
+ # Registering under an existing name repoints it — explicit, visible in
556
+ # the response; the tool table is dev-scoped runtime state.
557
+ old = state.tools.get(name)
558
+ if isinstance(old, StdioMcpServer):
559
+ await old.close()
560
+ state.tools[name] = url
561
+ return JSONResponse({
562
+ "registered": name,
563
+ "proxied_base": f"/t/{name}/",
564
+ "server": info["server"],
565
+ "protocol_version": info["protocol_version"],
566
+ "transport": "http",
567
+ "tools": info["tools"],
568
+ })
569
+
570
+ async def healthz(request: Request) -> Response:
571
+ return JSONResponse({"ok": True, "armed": state.runs.active is not None})
572
+
573
+ @asynccontextmanager
574
+ async def lifespan(_app: Starlette) -> Any: # noqa: ANN401 - CM protocol
575
+ yield
576
+ # Spawned stdio-MCP gateways die with the proxy, not as orphans.
577
+ for upstream in state.tools.values():
578
+ if isinstance(upstream, StdioMcpServer):
579
+ await upstream.close()
580
+
581
+ app = Starlette(lifespan=lifespan, routes=[
582
+ Route("/axor/healthz", healthz),
583
+ Route("/axor/preflight", preflight),
584
+ Route("/axor/mcp/discover", mcp_discover, methods=["POST"]),
585
+ Route("/axor/governed/spawn", spawn_governed, methods=["POST"]),
586
+ Route("/axor/governed/spawn-tree", spawn_governed_tree_route, methods=["POST"]),
587
+ Route("/axor/runs", start_run, methods=["POST"]),
588
+ Route("/axor/runs/{run_id}/simulate", simulate, methods=["POST"]),
589
+ Route("/axor/runs/{run_id}/claim", submit_claim, methods=["POST"]),
590
+ Route("/axor/runs/{run_id}", get_run),
591
+ Mount("/mock", app=mock_tools_app()),
592
+ Route(
593
+ "/t/{tool}/{path:path}", tool_route,
594
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"],
595
+ ),
596
+ ])
597
+ app.state.proxy = state
598
+ return app
axor_proxy/errors.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class ProxyError(Exception):
5
+ """Base for axor-proxy errors."""
6
+
7
+
8
+ class UpstreamUnreachable(ProxyError):
9
+ """Declared tool endpoint did not answer the pre-flight ping (onboarding step 3)."""