Live REA integration: MCP client + adapter + pull command + offline e2e demo

REAgraph now connects to REA at runtime (MCP client, no vendored REA code):
- rea_client.py  ReaClient implements bridge.ReaSource; spawns/connects to REA
                 via $REAGRAPH_REA_CMD (default: npx -y rea-agents@latest), lists
                 tools, calls analyses, yields fragments. mcp lazy-imported;
                 connect-vs-logic split so it tests offline; fail-open per tool.
- rea_adapter.py REA JavaScript Application Graph -> node-link fragments, grounded
                 against morluto/rea's real JAG schema (node_id/observations/edges/
                 relation/epistemic 'state'); fail-open to an evidence node on any
                 unknown tool or garbage shape; never raises.
- cli: 'reagraph pull <target>' connects to REA and streams findings into the
       graph via bridge (crash-safe). Full CLI: add/diff/stats/summary/coverage/
       query/pull/serve.
- examples/fake_rea_mcp_server.py + rea_live_demo.sh exercise the whole loop
       offline (no REA/node); degrades to 'reagraph add' when mcp is absent.
- docs/REA_INTEGRATION.md; DESIGN note on optional future acceleration.

42 tests pass. Independent project; not affiliated with REA.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
safishamsiandClaude Opus 4.8 committed 2026-10-16 20:41:36 +01:00
1 parent 0ce3af6d1c
commit 0ca9079744
12 files changed
+1419 -2

No files matched your search

+4
View File
@@ -47,6 +47,10 @@ findings accumulate.
only inferred vs unverified, with the frontier of "probe here next" and the
explicit reminder that absence of a finding is not proof of absence.
- `reagraph stats` — totals and confidence breakdown of the accumulated graph.
- `reagraph pull <target>` — connect to **REA** (spawned via `npx rea-agents`,
overridable with `$REAGRAPH_REA_CMD`), analyze a target, and fold each finding
into the graph as it arrives. REAgraph is an MCP *client* of REA; it vendors no
REA code. Degrades gracefully when REA or `mcp` is absent (use `reagraph add`).
- `reagraph serve` — run the REAgraph **MCP server** (`remember` / `recall` /
`diff` / `stats`) so any agent uses REAgraph as its reverse-engineering memory
(needs the `mcp` package).
+10
View File
@@ -54,6 +54,16 @@ The graph is written in graphify's node-link JSON, so `graphify query/path/expla
fingerprints, confidence-weighted) to answer "does shipped == source" — the
residual (shipped-but-unexplained) is the supply-chain signal.
## Future: optional acceleration (not now)
The one compute-heavy step on the horizon is structural alignment / community
detection / fingerprinting over million-node fused graphs (roadmap item 5). If
that becomes the bottleneck, acceleration would be an OPTIONAL, subprocess-isolated
backend, never a core dependency, so `pip install reagraph` stays stdlib-simple.
Preferred order: mature graph-algorithm libraries first (rustworkx, igraph, or
cuGraph on GPU); a massively-parallel runtime like Bend/HVM only if its model
earns its keep, given its early stage and the JSON<->tiny-types interop cost.
## Non-goals / honesty
- REAgraph does **not** decompile or reverse-engineer anything itself — it depends
+175
View File
@@ -0,0 +1,175 @@
# REA integration
This document explains how REAgraph connects to REA. The short version: REAgraph
does not contain REA. It talks to a running REA over the Model Context Protocol
(MCP), reads the findings REA produces, and folds them into a persistent graph on
disk. REA stays REA; REAgraph stays the thin memory and structure layer on top.
The wiring described here is in the tree and tested: `reagraph.rea_client` (the
MCP client), `reagraph.rea_adapter` (the result adapter, grounded against REA's
JavaScript Application Graph schema), `reagraph.bridge` (incremental merge), and
the `reagraph pull <target>` command. The `mcp` package is lazy-imported, so the
rest of REAgraph works without it, and `examples/fake_rea_mcp_server.py` lets you
exercise the whole loop offline (see `examples/rea_live_demo.sh`).
## The integration model: client, not vendor
REAgraph does not vendor REA's source. REA is an MCP server; REAgraph is an MCP
client. At analysis time REAgraph spawns a REA process on demand, speaks MCP to
it, calls REA's analysis tools, and collects the results. The default spawn
command is:
```
npx -y rea-agents@latest
```
That default is overridable. Set the `REAGRAPH_REA_CMD` environment variable to
point at any command that starts a REA MCP server (a pinned version, a local
checkout, a wrapper script, a container entrypoint). REAgraph runs whatever that
variable names and talks MCP to it; it does not care how REA was installed.
Why integrate at the protocol boundary instead of copying REA's code:
- Licensing. REA is MIT, graphify is Apache-2.0, and REAgraph is MIT. Keeping the
three as separate projects that talk over formats and protocols keeps each
license clean and avoids mixing code under different terms into one tree.
- Maintenance. Consuming REA's output rather than forking it means REAgraph rides
REA's upstream. Improvements to REA's analysis arrive for free; REAgraph only
maintains the thin adapter and client in between. The same logic applies on the
query side, where REAgraph delegates to graphify rather than embedding it.
If you want REA's source checked out locally for development (to read it, pin it,
or patch it), the clean option is a git submodule. A submodule keeps REA's
history and license separate and under REA's own path, so nothing about
REAgraph's licensing or provenance changes. It is a convenience for developers,
not a dependency: REAgraph still talks to REA over MCP at runtime, submodule or
not.
## Data flow
A REA tool result is turned into a graph fragment, merged into the persistent
graph one finding at a time, and then queried through graphify.
```
REA (MCP server)
|
| tool result (REA's analysis output)
v
rea_client (MCP client: spawns REA, calls its tools, yields results)
|
| raw result dict
v
rea_adapter.to_fragment (REA result -> node-link / JAG fragment)
|
| fragment dict
v
bridge.ingest_stream (per finding: normalize -> merge -> SAVE)
| crash-safe (saved after every merge)
| fail-open (one bad finding is skipped, not fatal)
v
reagraph.json (persistent memory, graphify node-link JSON)
|
| query / path / explain
v
graphify
```
The pieces in detail:
- `rea_client` implements the bridge's `ReaSource` contract. `ReaSource` is a
Protocol with a single method, `findings() -> Iterable[dict]`. The client's job
is to spawn REA (per `REAGRAPH_REA_CMD`, default `npx -y rea-agents@latest`),
drive its analysis tools over MCP, and yield one result dict per finding.
Because the contract is just "yield dicts", REAgraph never imports REA and is
never coupled to it at the code level.
- `rea_adapter.to_fragment` maps one REA tool result into the fragment shape the
bridge expects: either a JAG document (nodes and edges carrying an `epistemic`
state) or an already node-link document. The adapter is the one place that
knows REA's result shape; everything downstream is REA-agnostic.
- `bridge.ingest_stream` takes a source (or any iterable of dicts), and for each
finding: normalizes it to a node-link fragment, merges it into the on-disk
graph, and saves immediately. JAG epistemic states map to graphify confidence
(`observed` becomes `EXTRACTED`, `inferred` becomes `INFERRED`, `unavailable`
edges are dropped), and the original relation is preserved in `context` as
`rea:<relation>` so provenance is never lost. Saving after every merge is what
makes the stream crash-safe: if the run dies on finding N, findings 1 through
N-1 are already durably on disk. Skipping a malformed finding (logged to stderr,
never raised) is what makes it fail-open: one bad symbol never discards the
whole analysis.
- `reagraph.json` is the persistent graph, written in graphify's node-link JSON.
Node identity is the node `id`; edge identity is the tuple
`(source, target, relation)`. Identity is never a byte offset, so re-observing a
thing dedups and upgrades its confidence rather than duplicating it.
- graphify reads that same file for query, path, and explain, for example
`graphify query "where is the license checked" --graph reagraph.json`.
## How to use it
The live path, once the REA client is wired, is: point REAgraph at REA and let it
stream findings into the graph. REA is spawned on demand, so there is nothing to
start by hand.
- Default command: `npx -y rea-agents@latest`. This fetches and runs REA's MCP
server, so a recent Node toolchain is the only prerequisite for the static
analyses.
- Override: set `REAGRAPH_REA_CMD` to run a different REA (pinned version, local
checkout, container). REAgraph runs exactly that command and speaks MCP to it.
Which analyses need a native engine, and which do not:
- No native engine required. Static analysis of JavaScript, TypeScript, and
Electron applications, and other targets where symbols and strings survive
(for example .NET and WASM). These are text-like artifacts that REA can reason
over without a disassembler, so the default `npx` REA is enough.
- Native engine required. Compiled native binaries (stripped or otherwise) that
need a disassembler or decompiler. REA drives one of Ghidra, Hopper, or IDA for
these. That engine is REA's own dependency: you install and configure it the way
REA documents, and REAgraph inherits whatever REA can do once it is set up.
REAgraph adds no native analysis of its own.
Graceful degradation when REA or MCP is absent. REAgraph is useful without a live
REA at all:
- Offline `reagraph add`. If you already have REA findings on disk (JAG JSON, or
any node-link graph), fold them into memory directly:
`reagraph add finding.jag.json`. This path uses only the `ingest` and `memory`
modules and needs neither REA nor the `mcp` package. `reagraph diff`, `stats`,
`summary`, and `coverage` are all offline too.
- A fake source for the live loop without REA. The bridge consumes any object
satisfying the `ReaSource` contract, so a fake in-memory source can exercise the
whole ingest loop (merge, save, dedup, confidence upgrade, fail-open skip)
without spawning REA. The test suite drives the bridge this way
(`tests/test_bridge.py`), and a fake server under `examples/` serves the same
purpose for trying the integration locally. `bridge.FileReaSource` is the
built-in version of this: give it a list of finding files and it yields them one
per file, skipping any that fail to load.
- Missing `mcp`. The `mcp` package is only needed to run or speak to an MCP
server. It is lazy-imported, so everything above (offline add, diff, the fake
source) works whether or not `mcp` is installed. Only `reagraph serve` and the
live REA client actually require it.
## Limitations
This integration is deliberately honest about what it does not guarantee.
- REA's output format moves fast. REA is actively developed and its result shape
changes. `rea_adapter.to_fragment` is therefore best-effort and fail-open: it
maps the fields it recognizes and the bridge skips (with a stderr note) any
finding it cannot normalize, rather than aborting the run. A REA release can
still change a field the adapter has not caught up to; when that happens you get
skipped findings and a log line, not a crash, and the adapter is the one place
to update.
- Native analysis inherits REA's setup requirements. Anything that needs Ghidra,
Hopper, or IDA is exactly as capable, and exactly as fiddly to install, as REA
itself. REAgraph does not package, bundle, or simplify those engines; if REA
cannot analyze a given native binary in your environment, neither can REAgraph.
- Privacy and legal responsibility is yours and unchanged. REA may send analysis
work to a model provider, so results and artifact details can leave your
machine; that is a property of running REA, and REAgraph routing REA's output
into a local graph does not change it. You are responsible for having
authorization to analyze whatever target you point this at. REAgraph adds
memory, structure, and diff on top of REA; it adds no new permission to analyze
anything and makes no legal or privacy guarantee about what you choose to run.
REAgraph is an independent project and is not affiliated with or endorsed by the
REA project.
+41
View File
@@ -27,3 +27,44 @@ The script uses `./.venv/bin/reagraph` if it exists, otherwise falls back to
byte-offset diff would report "everything changed". REAgraph diffs by
node/edge identity, so it reports the truth: mostly unchanged, one node added,
one edge added, `session_create` rewired — the real signal isn't buried.
## Live, end-to-end demo (no real REA or Node required)
```bash
bash examples/rea_live_demo.sh
```
This shows the *whole* pipeline the way it runs against real REA, but offline:
```
fake REA MCP server -> MCP client -> bridge -> persistent graph
```
- **The fake REA server** (`fake_rea_mcp_server.py`) is a tiny MCP stdio server
that stands in for real REA. It advertises REA-like tools
(`analyze-javascript-application`, `list-detected-ipc-channels`) and returns a
small, realistic **canned** JAG finding — an Electron-style JS app with
modules, imports, an IPC channel, and one injected-looking exfil edge
(`renderer → collect.evil-metrics.example`, marked `inferred`). The canned
data lives in the `CANNED_JS_ANALYSIS` module constant so it is easy to read.
- **The live loop** (run when the optional `mcp` package is installed): a small
inline MCP client launches the server over stdio, lists its tools, calls
`analyze-javascript-application`, and streams the finding through
`reagraph.bridge` into a durable graph at `/tmp/reagraph_live_demo.json`
(saved after every merge — crash-safe). It then prints `reagraph stats` and
`reagraph summary`.
- **Graceful fallback:** the live loop needs `mcp`. If it is not installed, the
script says so and falls back to the offline path (feeding
`sample_findings/*.jag.json` through `reagraph add`), reaching the same end
state. The script **always exits 0**, with or without `mcp`.
### Point at real REA
To drive the pipeline with real REA instead of the fake server, point
`REAGRAPH_REA_CMD` at the real MCP command and re-run — the inline client will
launch that instead:
```bash
export REAGRAPH_REA_CMD="npx -y rea-agents@latest"
bash examples/rea_live_demo.sh
```
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""A tiny FAKE REA MCP server, so the REAgraph pipeline can be demoed offline.
Real REA (reverse-engineering agents) is a Node tool you normally launch with
`npx -y rea-agents@latest`. You should not need it — or Node — just to see the
client -> adapter -> bridge -> graph loop work. This module stands in for REA:
it speaks the MCP tool protocol over stdio, advertises a couple of REA-like
tools, and returns a small, realistic *canned* JS-analysis finding so a client
can connect, list tools, call a tool, and get something the bridge can ingest.
The canned result is a JAG-style doc (nodes/edges carrying an `epistemic`
state), which is exactly what `reagraph.ingest` / `reagraph.bridge` already know
how to fold into the graph. It models a small Electron-style JavaScript app:
modules, a few imports, an IPC channel, and ONE injected-looking exfil edge
(a renderer POSTing user data to an external telemetry host) marked `inferred`
so you can see observed-vs-inferred confidence flow all the way through.
Run it directly as an MCP stdio server:
python examples/fake_rea_mcp_server.py # needs the `mcp` package
It is deliberately dependency-light: only the optional `mcp` package is needed,
and its import is guarded with a clear message so running without it fails
loudly instead of mysteriously. The canned data lives in a module constant
(CANNED_JS_ANALYSIS) so it is easy to read and tweak.
"""
from __future__ import annotations
import json
import sys
# ---------------------------------------------------------------------------
# Canned analysis result. A JAG doc: nodes/edges with an `epistemic` state
# (observed -> EXTRACTED, inferred -> INFERRED). Kept here as a plain constant
# so it is trivial to read and edit. This is what a real REA
# "analyze-javascript-application" run would (roughly) hand back.
# ---------------------------------------------------------------------------
CANNED_JS_ANALYSIS: dict = {
"artifact": "acme-desktop v2.3.1 (Electron)",
"coverage": "main/renderer/preload bundles + detected IPC + network egress",
"nodes": [
# --- modules / bundles (directly observed in the packaged app) ---
{"id": "main", "label": "main.js", "epistemic": "observed",
"file_type": "code", "source_file": "app/main.js"},
{"id": "renderer", "label": "renderer.js", "epistemic": "observed",
"file_type": "code", "source_file": "app/renderer.js"},
{"id": "preload", "label": "preload.js", "epistemic": "observed",
"file_type": "code", "source_file": "app/preload.js"},
# --- imported packages (observed in the dependency graph) ---
{"id": "electron", "label": "electron", "epistemic": "observed",
"file_type": "dependency", "source_file": "node_modules/electron"},
{"id": "node:fs", "label": "fs", "epistemic": "observed",
"file_type": "dependency"},
{"id": "node:https", "label": "https", "epistemic": "observed",
"file_type": "dependency"},
# --- the IPC channel wiring renderer <-> main (observed) ---
{"id": "ipc:app:getUserProfile", "label": "ipc channel app:getUserProfile",
"epistemic": "observed", "file_type": "channel"},
# --- the suspicious bit: an external telemetry/exfil endpoint. We SAW
# the host string, but the exfil *intent* is inferred, not proven. ---
{"id": "host:collect.evil-metrics.example", "label": "collect.evil-metrics.example",
"epistemic": "inferred", "file_type": "endpoint"},
],
"edges": [
# Module graph (observed).
{"source": "main", "target": "electron", "relation": "imports", "epistemic": "observed"},
{"source": "main", "target": "node:fs", "relation": "imports", "epistemic": "observed"},
{"source": "renderer", "target": "electron", "relation": "imports", "epistemic": "observed"},
{"source": "preload", "target": "electron", "relation": "imports", "epistemic": "observed"},
# IPC wiring (observed): preload exposes a channel the renderer invokes
# and main handles.
{"source": "preload", "target": "ipc:app:getUserProfile", "relation": "exposes", "epistemic": "observed"},
{"source": "renderer", "target": "ipc:app:getUserProfile", "relation": "invokes", "epistemic": "observed"},
{"source": "main", "target": "ipc:app:getUserProfile", "relation": "handles", "epistemic": "observed"},
# The injected-looking exfil edge: renderer pulls the user profile over
# IPC and then POSTs it to an external host. The network call is
# observed; that it is *exfiltration* is inferred.
{"source": "renderer", "target": "node:https", "relation": "imports", "epistemic": "observed"},
{"source": "renderer", "target": "host:collect.evil-metrics.example",
"relation": "exfiltrates", "epistemic": "inferred"},
],
}
# A second, tiny tool result: just the channels, for a client that wants a
# cheap list without the full analysis.
CANNED_CHANNELS: dict = {
"channels": [
{"name": "app:getUserProfile", "direction": "renderer->main", "epistemic": "observed"},
],
}
# Tool advertised names (REA-like, hyphenated) kept as constants for reuse.
TOOL_ANALYZE = "analyze-javascript-application"
TOOL_CHANNELS = "list-detected-ipc-channels"
def _require_server_class():
"""Return the high-level MCP server class, across SDK versions.
The high-level server is `FastMCP` in `mcp` 1.x and was renamed to
`MCPServer` in `mcp` 2.x; both expose the same `name` ctor, `.tool(name=,
description=)` decorator, and `.run(transport="stdio")`. We try both and
exit with a clear, actionable message if neither (nor `mcp` itself) is
available.
"""
# mcp 1.x
try:
from mcp.server.fastmcp import FastMCP
return FastMCP
except Exception:
pass
# mcp 2.x
try:
from mcp.server.mcpserver import MCPServer
return MCPServer
except Exception as exc:
sys.stderr.write(
"fake_rea_mcp_server: could not load an MCP high-level server class, "
"so this stdio server cannot start.\n"
f" (import error: {exc})\n"
" The optional 'mcp' package is likely not installed. Install it with:\n"
" pip install mcp (or: uv pip install mcp)\n"
" The rea_live_demo.sh script falls back to an offline path when "
"'mcp' is missing, so you can still see the full pipeline.\n"
)
raise SystemExit(1)
def build_server():
"""Construct the MCP server advertising the two REA-like tools."""
ServerClass = _require_server_class()
server = ServerClass("fake-rea")
@server.tool(
name=TOOL_ANALYZE,
description=(
"Analyze a JavaScript/Electron application and return a JAG-style "
"finding (modules, imports, IPC channels, and any suspicious "
"network egress). Stand-in for real REA; returns canned data."
),
)
def analyze_javascript_application(path: str = ".") -> str:
# `path` is accepted to mirror the real tool's shape; the fake server
# ignores it and always returns the same canned finding.
return json.dumps(CANNED_JS_ANALYSIS, indent=2)
@server.tool(
name=TOOL_CHANNELS,
description="List the IPC channels detected in the last analysis (canned).",
)
def list_detected_ipc_channels() -> str:
return json.dumps(CANNED_CHANNELS, indent=2)
return server
def main() -> int:
# FastMCP.run() defaults to the stdio transport, which is what an MCP client
# launching us as a subprocess expects.
build_server().run()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
#
# REAgraph LIVE demo: the whole pipeline end to end, WITHOUT installing REA
# or Node.
#
# fake REA MCP server -> MCP client -> bridge -> persistent graph
#
# Instead of real REA, this drives a tiny FAKE REA MCP server
# (examples/fake_rea_mcp_server.py) that speaks the MCP tool protocol over
# stdio and returns a canned JS-analysis finding. A small inline MCP client
# connects to it, lists its tools, calls "analyze-javascript-application",
# and streams the finding through reagraph.bridge into a durable /tmp graph.
# Then it prints `reagraph stats` and `reagraph summary`.
#
# To run against REAL REA instead of the fake server, point REAGRAPH_REA_CMD
# at the real MCP command and re-run -- the inline client will launch that
# instead of the fake server:
#
# export REAGRAPH_REA_CMD="npx -y rea-agents@latest"
# bash examples/rea_live_demo.sh
#
# DEGRADES GRACEFULLY: the live loop needs the optional `mcp` package. If `mcp`
# is not installed, this script prints a clear note and falls back to the
# offline path (feeding examples/sample_findings/*.jag.json through
# `reagraph add`) so it always succeeds and demonstrates the same end state.
set -euo pipefail
# Resolve paths relative to this script, so it runs from anywhere.
HERE="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$HERE/.." && pwd)"
FINDINGS="$HERE/sample_findings"
SERVER="$HERE/fake_rea_mcp_server.py"
GRAPH="/tmp/reagraph_live_demo.json"
# Make `import reagraph` / `python -m reagraph.cli` work regardless of cwd.
export PYTHONPATH="$REPO${PYTHONPATH:+:$PYTHONPATH}"
# Prefer the project venv's python/reagraph; fall back to the module form.
if [ -x "$REPO/.venv/bin/python" ]; then
PY="$REPO/.venv/bin/python"
else
PY="python3"
fi
if [ -x "$REPO/.venv/bin/reagraph" ]; then
REAGRAPH="$REPO/.venv/bin/reagraph"
else
REAGRAPH="$PY -m reagraph.cli"
fi
echo "== REAgraph LIVE demo (no real REA / Node required)"
echo "== python : $PY"
echo "== reagraph: $REAGRAPH"
echo "== server : $SERVER (fake REA MCP server)"
echo "== graph : $GRAPH"
echo
echo " Real REA instead of the fake server:"
echo " export REAGRAPH_REA_CMD=\"npx -y rea-agents@latest\" # then re-run"
echo
# Start from a clean slate so the demo is deterministic and re-runnable.
rm -f "$GRAPH"
# The offline path: fold the checked-in sample findings into the graph with
# `reagraph add`. Reaches the same end state as the live loop, with no `mcp`.
run_offline() {
echo " Feeding examples/sample_findings/*.jag.json through 'reagraph add'"
echo " reaches the SAME end state: a durable, merged graph."
rm -f "$GRAPH"
for f in "$FINDINGS"/*.jag.json; do
echo " + add $(basename "$f")"
$REAGRAPH add "$f" --graph "$GRAPH"
done
}
# The live loop as a function so a failure can fall back without aborting the
# script. The inline MCP client connects to the fake server (or REAGRAPH_REA_CMD
# if set), lists tools, calls the analyze tool, and folds the finding into the
# graph via reagraph.bridge. Kept inline so this demo needs no extra files and
# does NOT depend on the sibling rea_client/rea_adapter modules.
run_live() {
REAGRAPH_GRAPH="$GRAPH" REAGRAPH_SERVER="$SERVER" "$PY" <<'PYEOF'
import asyncio
import json
import os
import shlex
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from reagraph import bridge
GRAPH = os.environ["REAGRAPH_GRAPH"]
SERVER = os.environ["REAGRAPH_SERVER"]
TOOL = "analyze-javascript-application"
# Point at real REA with REAGRAPH_REA_CMD; otherwise launch the fake server.
rea_cmd = os.environ.get("REAGRAPH_REA_CMD")
if rea_cmd:
parts = shlex.split(rea_cmd)
params = StdioServerParameters(command=parts[0], args=parts[1:])
print(f" using REAGRAPH_REA_CMD: {rea_cmd}")
else:
params = StdioServerParameters(command=sys.executable, args=[SERVER])
print(f" launching fake server: {SERVER}")
def _text_of(result) -> str:
"""Pull the text payload out of an MCP tool-call result, tolerantly."""
# Newer SDKs expose structured content; fall back to text content blocks.
for block in getattr(result, "content", []) or []:
text = getattr(block, "text", None)
if text:
return text
raise RuntimeError("tool result had no text content")
async def main() -> int:
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
names = [t.name for t in tools.tools]
print(f" tools advertised: {names}")
result = await session.call_tool(TOOL, {"path": "."})
finding = json.loads(_text_of(result))
print(
f" finding received: {len(finding.get('nodes', []))} nodes, "
f"{len(finding.get('edges', []))} edges "
f"(artifact: {finding.get('artifact', '?')})"
)
# Stream the finding through the bridge: merged AND saved to disk.
def _on_merge(stats):
print(
f" merged -> graph now {stats['nodes']} nodes, "
f"{stats['edges']} edges"
)
bridge.ingest_stream([finding], graph_path=GRAPH, on_merge=_on_merge)
return 0
raise SystemExit(asyncio.run(main()))
PYEOF
}
# ---------------------------------------------------------------------------
# Does this interpreter have the optional `mcp` package? That decides whether
# we run the real live loop or the offline fallback. Either way we exit 0.
# ---------------------------------------------------------------------------
if $PY -c "import mcp" >/dev/null 2>&1; then
echo "---------------------------------------------------------------"
echo "LIVE mcp present -> fake REA server -> MCP client -> bridge -> graph"
echo " An MCP client launches the server over stdio, lists its tools,"
echo " calls 'analyze-javascript-application', and streams the finding"
echo " into a durable graph (saved after every merge, crash-safe)."
echo "---------------------------------------------------------------"
if run_live; then
:
else
echo
echo " (the live loop failed -- e.g. an incompatible 'mcp' build or a"
echo " mis-set REAGRAPH_REA_CMD. Falling back to the offline path so the"
echo " demo still reaches the same end state.)"
run_offline
fi
echo
else
echo "---------------------------------------------------------------"
echo "OFFLINE 'mcp' not installed -> falling back to the offline path"
echo " (install it with 'pip install mcp' to run the real live loop)."
echo "---------------------------------------------------------------"
run_offline
echo
fi
echo "---------------------------------------------------------------"
echo "reagraph stats -> what the accumulated graph now knows"
echo "---------------------------------------------------------------"
$REAGRAPH stats --graph "$GRAPH"
echo
echo "---------------------------------------------------------------"
echo "reagraph summary -> bounded map (god nodes + clusters)"
echo "---------------------------------------------------------------"
$REAGRAPH summary --graph "$GRAPH"
echo
echo "== done. persistent graph left at: $GRAPH"
+5 -2
View File
@@ -5,7 +5,10 @@ The two pieces that work today:
- `diff`: structural, byte-agnostic diff of two graphs.
"""
from . import memory, diff, ingest, summarize, coverage, bridge, serve
from . import memory, diff, ingest, summarize, coverage, bridge, serve, rea_adapter, rea_client
__all__ = ["memory", "diff", "ingest", "summarize", "coverage", "bridge", "serve"]
__all__ = [
"memory", "diff", "ingest", "summarize", "coverage",
"bridge", "serve", "rea_adapter", "rea_client",
]
__version__ = "0.0.1"
+20
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import argparse
import os
import shlex
import shutil
import subprocess
import sys
@@ -65,6 +66,18 @@ def _cmd_coverage(args) -> int:
return 0
def _cmd_pull(args) -> int:
# Connect to a running/spawned REA MCP server, analyze the target, and fold
# each finding into the graph as it arrives (crash-safe, fail-open).
from . import bridge, rea_client
cmd = shlex.split(args.rea_cmd) if args.rea_cmd else None
client = rea_client.ReaClient(target=args.target, server_cmd=cmd)
s = bridge.ingest_stream(client, args.graph)
print(f"pulled from REA into {args.graph}: {s['nodes']} nodes, {s['edges']} edges "
f"(EXTRACTED {s['edge_confidence']['EXTRACTED']} / INFERRED {s['edge_confidence']['INFERRED']})")
return 0
def _cmd_serve(args) -> int:
# The MCP server resolves its graph via REAGRAPH_GRAPH; forward --graph.
os.environ["REAGRAPH_GRAPH"] = args.graph
@@ -113,6 +126,13 @@ def build_parser() -> argparse.ArgumentParser:
q.add_argument("--graph", default=DEFAULT_GRAPH)
q.set_defaults(func=_cmd_query)
pl = sub.add_parser("pull", help="connect to REA, analyze a target, fold findings into the graph")
pl.add_argument("target", help="what REA should analyze (e.g. an unpacked app dir or file)")
pl.add_argument("--rea-cmd", default=None,
help="override the REA MCP server command (else $REAGRAPH_REA_CMD, else npx rea-agents)")
pl.add_argument("--graph", default=DEFAULT_GRAPH)
pl.set_defaults(func=_cmd_pull)
sv = sub.add_parser("serve", help="run the REAgraph MCP server (needs the 'mcp' package)")
sv.add_argument("--graph", default=DEFAULT_GRAPH)
sv.set_defaults(func=_cmd_serve)
+272
View File
@@ -0,0 +1,272 @@
"""Map REA MCP tool *results* onto node-link graph fragments REAgraph can merge.
REAgraph vendors no REA code and depends on nothing from REA at runtime. This
module only translates the *output shapes* a REA MCP server returns into the
same node-link fragment shape the rest of REAgraph speaks (see `reagraph.memory`
and `reagraph.ingest`), so a fragment produced here folds straight into the
persistent graph via `reagraph.memory.merge`.
Grounding (read-only research, not vendored): the primary REA tool we map is
`analyze_javascript_application`. Its result carries a *JavaScript Application
Graph* (REA's provider-neutral domain model) under
`structuredContent.normalized_result` -- see REA's
`docs/javascript-application-graph.md` and
`src/domain/javascript/javascriptApplicationGraphSchemas.ts`. That graph has the
top-level shape::
{"schema": "JavaScriptApplicationGraph",
"graph_id": "jag_<sha256>",
"root_node_ids": ["jag_node_<sha256>", ...],
"nodes": [{"node_id", "kind", "identity",
"observations": [{"label", "properties", "evidence"}, ...]}],
"edges": [{"edge_id", "source_node_id", "target_node_id",
"relation", "properties", "evidence"}],
"coverage": {...}, "limitations": [...]}
Every observation and edge carries an `evidence` block whose `state` field is
the epistemic marker (`observed` / `inferred` / `unknown` / `unavailable`) and
whose `location` points at an artifact path. The directed relation vocabulary is
`contains, loads, imports, maps_to, exposes, sends, invokes, handles, calls,
persists_to, observed_as, changed_from`; native add-ons/exports and their
`imports`/`exposes`/`calls` relations appear as ordinary JAG nodes/edges, so
native-symbol and import inspection is covered by the same mapping.
Epistemic -> confidence mirrors `reagraph.ingest`: observed -> EXTRACTED,
inferred -> INFERRED; an `unavailable` edge is dropped (nothing to assert); the
original relation is preserved as `context = "rea:<relation>"` so provenance is
never lost. Static structure with no explicit state defaults to EXTRACTED;
anything heuristic resolves to INFERRED.
REA's format moves fast and this adapter is deliberately best-effort and
FAIL-OPEN: an unknown tool, a missing field, or an unexpected shape never
raises. Anything we cannot map is wrapped as a single evidence node carrying a
raw summary, so a finding is never silently lost and a bad result never aborts a
stream.
"""
from __future__ import annotations
import hashlib
import json
import re
# REA tool names whose results we map specially. `analyze_javascript_application`
# emits a JavaScript Application Graph (static JS/Electron structure *plus*
# native add-on/export nodes and import edges), which is the shape we ground
# here. Anything not in this set fails open to an evidence node.
SUPPORTED_TOOLS: set[str] = {"analyze_javascript_application"}
# REA epistemic `state` -> graphify confidence. Absent/other -> handled below.
_STATE_TO_CONFIDENCE = {"observed": "EXTRACTED", "inferred": "INFERRED"}
# JAG directed relation vocabulary (for recognising a graph by shape).
_JAG_RELATIONS = frozenset(
{
"contains", "loads", "imports", "maps_to", "exposes", "sends",
"invokes", "handles", "calls", "persists_to", "observed_as",
"changed_from",
}
)
def to_fragment(tool_name: str, result: dict) -> dict:
"""Map a REA MCP tool result to a node-link fragment {"nodes", "links"}.
For a supported tool whose result carries a JavaScript Application Graph,
entities become nodes and relationships become edges, with confidence drawn
from each evidence `state` and `context = "rea:<relation>"`. For an unknown
tool, or a supported tool returning an unexpected shape, FAIL OPEN: the whole
result is wrapped as one evidence node so nothing is lost. Never raises.
"""
try:
if isinstance(result, dict) and tool_name in SUPPORTED_TOOLS:
graph = _find_jag(result)
if graph is not None:
fragment = _map_jag(graph)
if fragment["nodes"] or fragment["links"]:
return fragment
except Exception:
pass # fall through to fail-open; this adapter never raises
return _fail_open(tool_name, result)
# --- helpers (kept small; below the public API) -----------------------------
def _find_jag(result: dict) -> dict | None:
"""Locate the JavaScript Application Graph inside a REA tool result.
Evidence-producing REA tools return the operation result under
`structuredContent.normalized_result`; we also accept the graph passed in
directly or lightly wrapped. Returns the graph dict or None.
"""
structured = result.get("structuredContent")
candidates = [
result,
result.get("normalized_result"),
result.get("result"),
structured if isinstance(structured, dict) else None,
structured.get("normalized_result") if isinstance(structured, dict) else None,
]
for cand in candidates:
if isinstance(cand, dict) and _looks_like_jag(cand):
return cand
return None
def _looks_like_jag(graph: dict) -> bool:
"""True if `graph` has the JAG node/edge shape (schema or structural markers)."""
if graph.get("schema") == "JavaScriptApplicationGraph":
return True
nodes = graph.get("nodes")
edges = graph.get("edges")
if isinstance(nodes, list) and nodes and isinstance(nodes[0], dict):
if "node_id" in nodes[0] or "observations" in nodes[0]:
return True
if isinstance(edges, list) and edges and isinstance(edges[0], dict):
if "source_node_id" in edges[0]:
return True
return False
def _map_jag(graph: dict) -> dict:
"""Map a JAG graph to a node-link fragment. Each item is guarded; never raises."""
nodes = []
for n in graph.get("nodes", []) or []:
if not isinstance(n, dict):
continue
nid = n.get("node_id") or n.get("id")
if nid is None:
continue
node = {
"id": nid,
"label": _node_label(n, nid),
"file_type": "code",
"confidence": _node_confidence(n),
}
if n.get("kind"):
node["rea_kind"] = n["kind"]
src = _node_source_file(n)
if src:
node["source_file"] = src
nodes.append(node)
links = []
for e in graph.get("edges", []) or graph.get("links", []) or []:
if not isinstance(e, dict):
continue
state = _evidence_state(e.get("evidence"))
if state == "unavailable":
continue # nothing to assert
source = e.get("source_node_id") or e.get("source")
target = e.get("target_node_id") or e.get("target")
if source is None or target is None:
continue
rel = e.get("relation", "references")
links.append(
{
"source": source,
"target": target,
"relation": rel,
"confidence": _state_to_confidence(state),
"context": f"rea:{rel}",
}
)
return {"directed": True, "multigraph": False, "nodes": nodes, "links": links}
def _state_to_confidence(state) -> str:
"""observed -> EXTRACTED, inferred -> INFERRED; absent -> EXTRACTED (static
structure is observed by default); unknown/other -> INFERRED (heuristic)."""
if state is None:
return "EXTRACTED"
return _STATE_TO_CONFIDENCE.get(state, "INFERRED")
def _evidence_state(evidence):
"""The epistemic `state` from an evidence block (or None if absent)."""
if isinstance(evidence, dict):
return evidence.get("state")
return None
def _node_confidence(node: dict) -> str:
"""Strongest confidence across a node's observations (re-observation upgrades)."""
best = None # None -> no observation seen; default to EXTRACTED like static structure
for obs in node.get("observations", []) or []:
if not isinstance(obs, dict):
continue
conf = _state_to_confidence(_evidence_state(obs.get("evidence")))
if conf == "EXTRACTED":
return "EXTRACTED"
best = "INFERRED"
return best or "EXTRACTED"
def _node_label(node: dict, nid: str) -> str:
"""First non-null observation label, else the node id."""
for obs in node.get("observations", []) or []:
if isinstance(obs, dict) and obs.get("label"):
return obs["label"]
return node.get("label") or nid
def _node_source_file(node: dict):
"""Artifact path from the first observation's evidence location, if present."""
for obs in node.get("observations", []) or []:
if not isinstance(obs, dict):
continue
loc = (obs.get("evidence") or {}).get("location") if isinstance(obs.get("evidence"), dict) else None
value = loc.get("value") if isinstance(loc, dict) else None
if isinstance(value, dict) and value.get("path"):
return value["path"]
return None
def _fail_open(tool_name, result) -> dict:
"""Wrap an unmapped result as a single evidence node so nothing is lost."""
try:
name = str(tool_name) if tool_name is not None else "unknown"
digest = _stable_hash(result)
node_id = f"rea_evidence_{_slug(name)}_{digest}"
node = {
"id": node_id,
"label": f"REA {name} result",
"file_type": "evidence",
"confidence": "INFERRED",
"rea_tool": name,
"rea_summary": _summary(result),
}
return {"directed": True, "multigraph": False, "nodes": [node], "links": []}
except Exception:
# Absolute last resort: a constant node, so we still never raise.
return {
"directed": True,
"multigraph": False,
"nodes": [{"id": "rea_evidence_unknown", "label": "REA result",
"file_type": "evidence", "confidence": "INFERRED"}],
"links": [],
}
def _stable_hash(obj) -> str:
"""A short, deterministic hash of a result (order-independent for dicts)."""
try:
blob = json.dumps(obj, sort_keys=True, default=str, ensure_ascii=False)
except Exception:
blob = repr(obj)
return hashlib.sha256(blob.encode("utf-8", "replace")).hexdigest()[:16]
def _summary(obj, limit: int = 500) -> str:
"""A bounded, readable summary of a raw result for the evidence attribute."""
try:
text = json.dumps(obj, sort_keys=True, default=str, ensure_ascii=False)
except Exception:
text = repr(obj)
return text if len(text) <= limit else text[: limit - 1] + "…"
def _slug(name: str) -> str:
return re.sub(r"[^0-9a-zA-Z._-]+", "_", name) or "unknown"
+195
View File
@@ -0,0 +1,195 @@
"""Live REA MCP client: pull findings from REA and fold them into the graph.
REAgraph does NOT vendor REA. At runtime this client spawns the REA MCP server
(github.com/morluto/rea) over stdio, initializes a session, lists its tools, runs
the requested analyses, and converts each tool result into a node-link graph
fragment via the sibling ``reagraph.rea_adapter``. The fragments are exactly the
dicts ``reagraph.bridge.ingest_stream`` already accepts, so:
from reagraph import bridge
from reagraph.rea_client import ReaClient
bridge.ingest_stream(ReaClient("app.asar"), "reagraph.json")
folds live REA findings into the persistent graph crash-safely.
Design for testability: the connection (spawn + MCP handshake) is isolated in
``_connect``; the connection-INDEPENDENT logic (iterate tool results -> fragments
via the adapter, fail-open on a bad tool) lives in the async ``collect`` method,
which takes an already-connected session-like object and can be unit-tested with
a tiny fake. ``mcp`` is imported lazily inside ``_connect`` only, so this module
imports fine without ``mcp`` (or node/npx) installed.
"""
from __future__ import annotations
import json
import os
import shlex
import sys
from typing import Iterator, Mapping, Sequence
# The adapter is a sibling module (built in parallel). Import it per the shared
# contract; stay importable and independently testable even if it is not present
# yet — tests stub ``to_fragment`` and real runs will have it installed.
try:
from . import rea_adapter
except ImportError: # pragma: no cover - exercised only before the sibling lands
import types
rea_adapter = types.ModuleType("reagraph.rea_adapter")
def _to_fragment_missing(tool_name: str, result: dict) -> dict:
raise RuntimeError(
"reagraph.rea_adapter is not available; cannot convert REA results"
)
rea_adapter.to_fragment = _to_fragment_missing # type: ignore[attr-defined]
rea_adapter.SUPPORTED_TOOLS = set() # type: ignore[attr-defined]
# npx-based default: run REA straight from npm with no local checkout. Override
# via the constructor or the REAGRAPH_REA_CMD env var.
DEFAULT_SERVER_CMD = ["npx", "-y", "rea-agents@latest"]
# Sensible defaults for static JS / Electron analysis, which needs no Ghidra or
# native disassembly. These are intersected with the server's actual ``list_tools``
# and the adapter's ``SUPPORTED_TOOLS``, so a name REA does not offer is simply
# skipped rather than fatal. Override via the ``tools=`` constructor argument.
DEFAULT_TOOLS = ("analyze_app", "list_functions", "list_strings")
class ReaClient:
"""A ``bridge.ReaSource`` backed by a live REA MCP server over stdio."""
def __init__(
self,
target: str = ".",
tools: Sequence[str] | None = None,
*,
server_cmd: Sequence[str] | None = None,
tool_args: Mapping[str, Mapping] | None = None,
) -> None:
self.target = target
self.tools = tuple(tools) if tools is not None else DEFAULT_TOOLS
self.server_cmd = self._resolve_cmd(server_cmd)
self.tool_args = {k: dict(v) for k, v in (tool_args or {}).items()}
# -- construction helpers (no I/O, no process spawn) ------------------
@staticmethod
def _resolve_cmd(server_cmd: Sequence[str] | None) -> list[str]:
"""Explicit arg > REAGRAPH_REA_CMD env > npx default. Never spawns."""
if server_cmd:
return list(server_cmd)
env = os.environ.get("REAGRAPH_REA_CMD")
if env:
return shlex.split(env)
return list(DEFAULT_SERVER_CMD)
def _args_for(self, tool: str) -> dict:
"""Call arguments for a tool: per-tool override, else the target path."""
if tool in self.tool_args:
return dict(self.tool_args[tool])
return {"path": self.target}
# -- ReaSource interface ---------------------------------------------
def findings(self) -> Iterator[dict]:
"""Yield node-link fragments from a live REA run (bridge.ReaSource)."""
# Collected inside the live session, then yielded: the MCP session must
# stay open while tools run. bridge.ingest_stream still saves after each
# merge, so crash-safety is preserved as fragments are consumed.
import asyncio
for fragment in asyncio.run(self._connect()):
yield fragment
# -- connection-INDEPENDENT logic (unit-tested with a fake session) ---
async def collect(self, session) -> list[dict]:
"""Run the requested analyses on an already-connected session-like object.
``session`` must expose async ``list_tools()`` and ``call_tool(name, args)``
(the shape of ``mcp.ClientSession``). Returns one fragment per tool that
succeeded. Fail-open: a tool whose call or conversion raises is logged to
stderr and skipped, so one failing REA tool never aborts the whole pull.
"""
available = await self._list_tool_names(session)
supported = set(getattr(rea_adapter, "SUPPORTED_TOOLS", set()) or ())
fragments: list[dict] = []
for tool in self.tools:
if tool not in available:
continue # REA did not offer this tool
if supported and tool not in supported:
continue # adapter cannot convert it
try:
result = await session.call_tool(tool, self._args_for(tool))
fragment = rea_adapter.to_fragment(tool, _result_payload(result))
except Exception as exc: # fail-open: skip one bad tool, keep going
print(
f"reagraph.rea_client: skipped tool {tool}: {exc}",
file=sys.stderr,
)
continue
fragments.append(fragment)
return fragments
@staticmethod
async def _list_tool_names(session) -> set:
"""Tool names the server offers (tolerant of result/list shapes)."""
listed = await session.list_tools()
tools = getattr(listed, "tools", listed)
return {getattr(t, "name", t) for t in tools}
# -- connection (lazy mcp import; the only part that spawns a process) -
async def _connect(self) -> list[dict]:
"""Spawn REA over stdio, handshake, and hand the session to ``collect``."""
try:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
except ImportError as exc:
raise RuntimeError(
"reagraph.rea_client needs the 'mcp' package to reach REA. "
"Install it (pip install mcp) and point REAGRAPH_REA_CMD at your "
"REA MCP server command (default: "
f"{' '.join(DEFAULT_SERVER_CMD)})."
) from exc
params = StdioServerParameters(
command=self.server_cmd[0], args=list(self.server_cmd[1:])
)
try:
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
return await self.collect(session)
except FileNotFoundError as exc:
raise RuntimeError(
f"could not launch REA MCP server {self.server_cmd!r}: {exc}. "
"Install node/npx (the default uses 'npx') or set REAGRAPH_REA_CMD "
"to a working REA MCP server command."
) from exc
def _result_payload(result) -> dict:
"""Normalize an MCP tool result into the plain dict the adapter expects.
Plain dicts (fakes, already-decoded results) pass through. An ``mcp``
``CallToolResult`` is reduced to its structured content, else the first text
block parsed as JSON, else an empty dict.
"""
if isinstance(result, dict):
return result
structured = getattr(result, "structuredContent", None)
if isinstance(structured, dict):
return structured
for block in getattr(result, "content", None) or ():
text = getattr(block, "text", None)
if text:
try:
return json.loads(text)
except (ValueError, TypeError):
return {"text": text}
return {}
+189
View File
@@ -0,0 +1,189 @@
"""The REA-output adapter, tested against realistic REA tool-result shapes.
No REA/MCP server is needed: the fixtures reproduce the output shapes REA's
`analyze_javascript_application` tool returns -- a JavaScript Application Graph
(nodes with `node_id`/`kind`/`observations`, edges with
`source_node_id`/`target_node_id`/`relation`, each carrying an `evidence` block
whose `state` is the epistemic marker) under `structuredContent.normalized_result`.
Shapes grounded in REA's `docs/javascript-application-graph.md` and
`src/domain/javascript/javascriptApplicationGraphSchemas.ts`.
We assert the mapping (relations, confidence from epistemic state, context
`rea:<relation>`, inferred -> INFERRED, unavailable edge dropped), that an
unknown/garbage result fails open to one evidence node without raising, and that
every fragment merges cleanly via `reagraph.memory.merge`.
"""
from reagraph import memory, rea_adapter
def _evidence(state, path="dist/app.js", authority="ast-static-analysis"):
"""A minimal REA evidence block in the real shape (state is the epistemic marker)."""
return {
"authority": authority,
"state": state,
"confidence": "exact" if state == "observed" else "high",
"artifact": {"available": True, "artifact_id": "art_" + "2" * 64, "sha256": "2" * 64},
"location": {"available": True, "value": {"kind": "artifact-path", "path": path}},
"extractor": {"name": "rea", "version": "1.0.0", "operation": "observe", "executable_sha256": None},
"coverage": {"status": "complete", "truncated": False, "omitted_count": 0, "limits": []},
"limitations": [] if state == "observed" else ["Inferred from static syntax."],
"evidence_ids": [],
}
def _node(node_id, kind, label, state, path="dist/app.js", authority="ast-static-analysis"):
return {
"node_id": node_id,
"kind": kind,
"identity": {"strategy": "artifact-local-key", "stability": "artifact-version"},
"observations": [
{"label": label, "properties": {}, "evidence": _evidence(state, path, authority)}
],
}
def _edge(source, target, relation, state, path="dist/app.js"):
return {
"edge_id": f"jag_edge_{relation}_{source[-4:]}_{target[-4:]}",
"source_node_id": source,
"target_node_id": target,
"relation": relation,
"properties": {},
"evidence": _evidence(state, path),
}
def _js_analysis_result():
"""A realistic `analyze_javascript_application` MCP result: Electron app with
a package -> asar -> preload chain, a contextBridge API, an IPC channel +
handler, a native add-on import, and one `unavailable` (ambiguous) edge."""
pkg = "jag_node_" + "a" * 64
asar = "jag_node_" + "b" * 64
preload = "jag_node_" + "c" * 64
bridge = "jag_node_" + "d" * 64
channel = "jag_node_" + "e" * 64
handler = "jag_node_" + "f" * 64
asset = "jag_node_" + "1" * 64
addon = "jag_node_" + "2" * 64
graph = {
"schema": "JavaScriptApplicationGraph",
"graph_id": "jag_" + "9" * 64,
"root_node_ids": [pkg],
"nodes": [
_node(pkg, "package", "Desktop package", "observed", "package.json", "artifact-bytes"),
_node(asar, "artifact", "resources/app.asar", "observed", "resources/app.asar", "artifact-bytes"),
_node(preload, "electron-preload", "preload", "observed", "dist/preload.js"),
_node(bridge, "context-bridge-api", "desktopApi", "observed", "dist/preload.js"),
_node(channel, "ipc-channel", "project:open", "observed", "dist/preload.js"),
_node(handler, "ipc-handler", "project open handler", "observed", "dist/main.js"),
_node(asset, "javascript-asset", "dist/main.js", "observed", "dist/main.js"),
_node(addon, "native-addon", "synthetic.node", "observed", "native/synthetic.node", "native-analysis-provider"),
],
"edges": [
_edge(pkg, asar, "contains", "observed", "resources/app.asar"),
_edge(asar, preload, "contains", "observed", "dist/preload.js"),
_edge(preload, bridge, "exposes", "observed", "dist/preload.js"),
# Heuristic static pairing of a contextBridge call to an IPC channel.
_edge(bridge, channel, "invokes", "inferred", "dist/preload.js"),
_edge(channel, handler, "handles", "observed", "dist/main.js"),
# Static `require` of a native binding -> import edge.
_edge(asset, addon, "imports", "observed", "dist/main.js"),
# Dynamic/ambiguous IPC: retained by REA without a resolvable target.
_edge(channel, handler, "sends", "unavailable", "dist/main.js"),
],
"coverage": {"status": "complete", "truncated": False, "omitted_count": 0, "limits": []},
"limitations": [],
}
# Evidence-producing tools nest the graph under structuredContent.normalized_result.
return {
"structuredContent": {"evidence_id": "ev_" + "0" * 32, "normalized_result": graph},
"isError": False,
}
def test_js_analysis_maps_nodes_edges_confidence_and_context():
frag = rea_adapter.to_fragment("analyze_javascript_application", _js_analysis_result())
# All 8 entities became nodes, keyed by REA node_id.
assert len(frag["nodes"]) == 8
by_id = {n["id"]: n for n in frag["nodes"]}
preload = next(n for n in frag["nodes"] if n["label"] == "preload")
assert preload["confidence"] == "EXTRACTED" # observed structure
assert preload["rea_kind"] == "electron-preload"
assert preload["source_file"] == "dist/preload.js"
# The native add-on is carried as a node (native-symbol/import coverage).
assert any(n["rea_kind"] == "native-addon" for n in frag["nodes"])
# Relations preserved; every edge context is rea:<relation>.
rels = {(e["relation"], e["confidence"], e["context"]) for e in frag["links"]}
assert ("contains", "EXTRACTED", "rea:contains") in rels
assert ("exposes", "EXTRACTED", "rea:exposes") in rels
assert ("handles", "EXTRACTED", "rea:handles") in rels
assert ("imports", "EXTRACTED", "rea:imports") in rels
for e in frag["links"]:
assert e["context"] == f"rea:{e['relation']}"
def test_inferred_becomes_INFERRED_and_unavailable_edge_dropped():
frag = rea_adapter.to_fragment("analyze_javascript_application", _js_analysis_result())
# The heuristic `invokes` edge is INFERRED, not EXTRACTED.
invokes = [e for e in frag["links"] if e["relation"] == "invokes"]
assert len(invokes) == 1
assert invokes[0]["confidence"] == "INFERRED"
assert invokes[0]["context"] == "rea:invokes"
# The `unavailable` (ambiguous) `sends` edge asserts nothing -> dropped.
assert not any(e["relation"] == "sends" for e in frag["links"])
# 7 edges in, 1 dropped -> 6 links out.
assert len(frag["links"]) == 6
def test_fragment_merges_cleanly_into_memory():
frag = rea_adapter.to_fragment("analyze_javascript_application", _js_analysis_result())
graph = memory.merge(memory.empty_graph(), frag)
stats = memory.stats(graph)
assert stats["nodes"] == 8
assert stats["edges"] == 6
assert stats["edge_confidence"]["EXTRACTED"] == 5 # all but the inferred invokes
assert stats["edge_confidence"]["INFERRED"] == 1
# Re-merging the same fragment dedups rather than duplicating.
memory.merge(graph, frag)
assert memory.stats(graph)["nodes"] == 8
assert memory.stats(graph)["edges"] == 6
def test_unknown_tool_fails_open_to_single_evidence_node():
# An unknown REA tool returning some opaque payload must not be lost or fatal.
result = {"structuredContent": {"normalized_result": {"functions": [1, 2, 3]}}}
frag = rea_adapter.to_fragment("inspect_macho", result)
assert len(frag["nodes"]) == 1
assert frag["links"] == []
node = frag["nodes"][0]
assert node["file_type"] == "evidence"
assert node["rea_tool"] == "inspect_macho"
assert node["id"].startswith("rea_evidence_inspect_macho_")
assert "functions" in node["rea_summary"] # raw summary preserved, nothing lost
# Stable id: the same result hashes to the same evidence node.
again = rea_adapter.to_fragment("inspect_macho", result)
assert again["nodes"][0]["id"] == node["id"]
# And it merges cleanly.
merged = memory.merge(memory.empty_graph(), frag)
assert memory.stats(merged)["nodes"] == 1
def test_garbage_input_never_raises_and_fails_open():
# Supported tool, but a totally unexpected shape -> fail open, no raise.
for bad in [None, {}, {"unexpected": object()}, "not a dict", 42, []]:
frag = rea_adapter.to_fragment("analyze_javascript_application", bad)
assert frag["links"] == []
assert len(frag["nodes"]) == 1
assert frag["nodes"][0]["file_type"] == "evidence"
# Always mergeable.
memory.merge(memory.empty_graph(), frag)
+149
View File
@@ -0,0 +1,149 @@
"""The REA MCP client, tested with NEITHER mcp NOR node NOR a real REA server.
The client's connection-independent logic (run tools -> fragments via the adapter,
fail-open on a bad tool) is exercised with a tiny fake session. The adapter's
``to_fragment`` is stubbed so this module is tested independently of the sibling
``reagraph.rea_adapter``.
"""
import asyncio
import sys
import types
from reagraph import memory, rea_client
# -- a tiny fake MCP session (duck-typed: async list_tools / call_tool) ------
class _Tool:
def __init__(self, name):
self.name = name
class _Listed:
def __init__(self, names):
self.tools = [_Tool(n) for n in names]
class FakeSession:
"""Offers three tools; two return results, one raises on call."""
def __init__(self, names):
self.names = names
self.calls = []
async def list_tools(self):
return _Listed(self.names)
async def call_tool(self, name, args):
self.calls.append((name, args))
if name == "boom":
raise RuntimeError("REA tool exploded")
return {"ok": name, "args": args}
def _stub_adapter(monkeypatch, names):
"""Stub the adapter so a fragment is trivially derived from the tool name."""
monkeypatch.setattr(
rea_client.rea_adapter,
"to_fragment",
lambda tool, res: {"nodes": [{"id": tool}], "links": []},
)
monkeypatch.setattr(rea_client.rea_adapter, "SUPPORTED_TOOLS", set(names))
def test_imports_without_mcp_installed():
# mcp is not a dependency; importing the module must not require it.
assert "mcp" not in sys.modules
assert hasattr(rea_client, "ReaClient")
def test_construction_never_spawns_a_process(monkeypatch):
# Lazy mcp import happens only inside _connect; construction must not reach it.
def _boom(*a, **k):
raise AssertionError("construction must not connect/spawn")
monkeypatch.setattr(rea_client.ReaClient, "_connect", _boom)
client = rea_client.ReaClient("app.asar", tools=("analyze_app",))
assert client.server_cmd == rea_client.DEFAULT_SERVER_CMD
assert "mcp" not in sys.modules # no MCP import triggered by constructing
def test_env_var_overrides_server_cmd(monkeypatch):
monkeypatch.setenv("REAGRAPH_REA_CMD", "myrea --stdio serve")
client = rea_client.ReaClient()
assert client.server_cmd == ["myrea", "--stdio", "serve"]
def test_collect_yields_fragments_and_skips_the_erroring_tool(monkeypatch):
names = ["analyze_app", "boom", "list_strings"]
_stub_adapter(monkeypatch, names)
client = rea_client.ReaClient("app.asar", tools=tuple(names))
session = FakeSession(names)
fragments = asyncio.run(client.collect(session))
# Three tools offered; the erroring one is skipped -> two fragments.
assert len(fragments) == 2
assert [f["nodes"][0]["id"] for f in fragments] == ["analyze_app", "list_strings"]
# The target path was passed through as the default tool argument.
assert ("analyze_app", {"path": "app.asar"}) in session.calls
# The yielded dicts are mergeable into an empty graph by reagraph.memory.
graph = memory.empty_graph()
for fragment in fragments:
memory.merge(graph, fragment)
stats = memory.stats(graph)
assert stats["nodes"] == 2
ids = {n["id"] for n in graph["nodes"]}
assert ids == {"analyze_app", "list_strings"}
def test_tools_not_offered_by_server_are_skipped(monkeypatch):
names = ["analyze_app"]
_stub_adapter(monkeypatch, ["analyze_app", "not_offered"])
# Request a tool the fake server does not list; it must be skipped silently.
client = rea_client.ReaClient("x", tools=("analyze_app", "not_offered"))
fragments = asyncio.run(client.collect(FakeSession(names)))
assert [f["nodes"][0]["id"] for f in fragments] == ["analyze_app"]
def test_adapter_error_is_fail_open(monkeypatch, capsys):
names = ["analyze_app"]
def _boom_adapter(tool, res):
raise ValueError("adapter cannot convert")
monkeypatch.setattr(rea_client.rea_adapter, "to_fragment", _boom_adapter)
monkeypatch.setattr(rea_client.rea_adapter, "SUPPORTED_TOOLS", set(names))
client = rea_client.ReaClient("x", tools=tuple(names))
fragments = asyncio.run(client.collect(FakeSession(names)))
assert fragments == [] # the single tool failed to convert, skipped not fatal
assert "skipped tool analyze_app" in capsys.readouterr().err
def test_connect_without_mcp_raises_actionable_error(monkeypatch):
# Ensure mcp truly is absent, then assert the error names REAGRAPH_REA_CMD.
monkeypatch.setitem(sys.modules, "mcp", None) # import mcp -> ImportError
client = rea_client.ReaClient("x")
try:
asyncio.run(client._connect())
except RuntimeError as exc:
assert "REAGRAPH_REA_CMD" in str(exc)
else: # pragma: no cover
raise AssertionError("expected a RuntimeError when mcp is missing")
def test_result_payload_handles_dicts_and_calltoolresult():
assert rea_client._result_payload({"a": 1}) == {"a": 1}
block = types.SimpleNamespace(text='{"nodes": [], "links": []}')
result = types.SimpleNamespace(structuredContent=None, content=[block])
assert rea_client._result_payload(result) == {"nodes": [], "links": []}
structured = types.SimpleNamespace(structuredContent={"k": "v"}, content=[])
assert rea_client._result_payload(structured) == {"k": "v"}