mirror of
https://github.com/Graphify-Labs/REAgraph.git
synced 2026-11-07 09:35:57 -10:00
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:
1 parent
0ce3af6d1c
commit
0ca9079744
12 files changed
+1419
-2
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
Executable
+166
@@ -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())
|
||||
Executable
+193
@@ -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,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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
Reference in new issue
Block a user