mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
feat: rea reverse-engineering skill + per-server MCP HTTP request timeout
Add a rea skill (skill-gated MCP): agents get binary and application package reverse engineering through a rea MCP server, with tools kept deferred so the 172-tool catalog never bloats the prompt until the skill loads. Remote MCP servers now take timeout_seconds (default 120): analysis servers with minutes-long tool calls were cut off mid-call by the fixed 120s client timeout. Applies to the Streamable HTTP transport; wired through discovery, the ScopeSentry sync client, the API and the MCP dialog. Closes #8
This commit is contained in:
+21
-16
@@ -812,20 +812,21 @@ func (d *DB) ListPromptVersions(agentID int64) ([]PromptVersion, error) {
|
||||
// ---------- MCP servers ----------
|
||||
|
||||
type MCPServer struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
Env json.RawMessage `json:"env"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Insecure bool `json:"insecure"` // http: skip TLS cert verification (self-signed servers, issue #108)
|
||||
Tools []string `json:"tools,omitempty"` // cached tool names (mcp_tools_cache)
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
Env json.RawMessage `json:"env"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Insecure bool `json:"insecure"` // http: skip TLS cert verification (self-signed servers, issue #108)
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // http: per-request timeout in seconds; 0 = default 120
|
||||
Tools []string `json:"tools,omitempty"` // cached tool names (mcp_tools_cache)
|
||||
}
|
||||
|
||||
func (d *DB) ListMCP() ([]*MCPServer, error) {
|
||||
rows, err := d.Query(`SELECT id,name,transport,COALESCE(command,''),args,env,COALESCE(url,''),enabled,insecure FROM mcp_servers ORDER BY id`)
|
||||
rows, err := d.Query(`SELECT id,name,transport,COALESCE(command,''),args,env,COALESCE(url,''),enabled,insecure,COALESCE(timeout_seconds,120) FROM mcp_servers ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -833,7 +834,7 @@ func (d *DB) ListMCP() ([]*MCPServer, error) {
|
||||
for rows.Next() {
|
||||
var m MCPServer
|
||||
var args, env []byte
|
||||
if err := rows.Scan(&m.ID, &m.Name, &m.Transport, &m.Command, &args, &env, &m.URL, &m.Enabled, &m.Insecure); err != nil {
|
||||
if err := rows.Scan(&m.ID, &m.Name, &m.Transport, &m.Command, &args, &env, &m.URL, &m.Enabled, &m.Insecure, &m.TimeoutSeconds); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -921,14 +922,18 @@ func (d *DB) SaveMCP(m *MCPServer) (int64, error) {
|
||||
if env == "" {
|
||||
env = "{}"
|
||||
}
|
||||
timeout := m.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 120
|
||||
}
|
||||
if m.ID == 0 {
|
||||
var id int64
|
||||
err := d.QueryRow(`INSERT INTO mcp_servers(name,transport,command,args,env,url,enabled,insecure) VALUES ($1,$2,NULLIF($3,''),$4,$5,NULLIF($6,''),$7,$8) RETURNING id`,
|
||||
m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled, m.Insecure).Scan(&id)
|
||||
err := d.QueryRow(`INSERT INTO mcp_servers(name,transport,command,args,env,url,enabled,insecure,timeout_seconds) VALUES ($1,$2,NULLIF($3,''),$4,$5,NULLIF($6,''),$7,$8,$9) RETURNING id`,
|
||||
m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled, m.Insecure, timeout).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE mcp_servers SET name=$1,transport=$2,command=NULLIF($3,''),args=$4,env=$5,url=NULLIF($6,''),enabled=$7,insecure=$8 WHERE id=$9`,
|
||||
m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled, m.Insecure, m.ID)
|
||||
_, err := d.Exec(`UPDATE mcp_servers SET name=$1,transport=$2,command=NULLIF($3,''),args=$4,env=$5,url=NULLIF($6,''),enabled=$7,insecure=$8,timeout_seconds=$9 WHERE id=$10`,
|
||||
m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled, m.Insecure, timeout, m.ID)
|
||||
return m.ID, err
|
||||
}
|
||||
|
||||
|
||||
@@ -859,6 +859,10 @@ ALTER TABLE mcp_servers ADD CONSTRAINT mcp_servers_transport_check
|
||||
CHECK (transport IN ('stdio','http','sse'));
|
||||
-- Backfill column for old databases (schema.sql runs Exec at every startup).
|
||||
ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS insecure BOOLEAN NOT NULL DEFAULT false;
|
||||
-- Per-server request timeout for remote MCP transports, in seconds (default 120). Analysis
|
||||
-- servers whose tools legitimately run minutes (e.g. binary reverse engineering) need room
|
||||
-- beyond the default without forcing every other server to wait as long.
|
||||
ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS timeout_seconds INT NOT NULL DEFAULT 120;
|
||||
DROP TRIGGER IF EXISTS trg_mcp_upd ON mcp_servers;
|
||||
CREATE TRIGGER trg_mcp_upd BEFORE UPDATE ON mcp_servers
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
+9
-2
@@ -96,12 +96,19 @@ func normalizeHeaders(headers map[string]string) (map[string]string, error) {
|
||||
// headers are sent on every request (Authorization, custom API keys, …).
|
||||
// When insecure is true, TLS certificate verification is skipped so servers that
|
||||
// present a self-signed certificate can still be reached (issue #108).
|
||||
func New(ctx context.Context, server, url string, headers map[string]string, insecure bool) (*Client, error) {
|
||||
// timeoutSeconds bounds each request (handshake, tools/list, one tools/call); 0
|
||||
// falls back to the 120s default. Analysis servers with minutes-long tool calls
|
||||
// raise it per server.
|
||||
func New(ctx context.Context, server, url string, headers map[string]string, insecure bool, timeoutSeconds int) (*Client, error) {
|
||||
cleanHeaders, err := normalizeHeaders(headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hc := &http.Client{Timeout: 120 * time.Second}
|
||||
timeout := 120 * time.Second
|
||||
if timeoutSeconds > 0 {
|
||||
timeout = time.Duration(timeoutSeconds) * time.Second
|
||||
}
|
||||
hc := &http.Client{Timeout: timeout}
|
||||
if insecure {
|
||||
hc.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package mcphttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A Streamable HTTP server whose slow_call tool takes 3s, so the client's
|
||||
// per-request timeout can be observed: 1s cuts the call off, 10s lets it finish.
|
||||
func TestStreamableClientRequestTimeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req rpcRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID)
|
||||
case "notifications/initialized":
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case "tools/list":
|
||||
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"tools":[{"name":"slow_call","description":"slow","inputSchema":{"type":"object"}}]}}`, req.ID)
|
||||
case "tools/call":
|
||||
time.Sleep(3 * time.Second)
|
||||
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"content":[{"type":"text","text":"done"}]}}`, req.ID)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// timeout_seconds=1: the slow call is cut off well before the server answers.
|
||||
c, err := New(ctx, "t", srv.URL, nil, false, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if _, err := c.Tools(ctx); err != nil {
|
||||
t.Fatalf("tools/list: %v", err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, err := c.Call(ctx, "slow_call", nil); err == nil {
|
||||
t.Fatal("expected the 1s-timeout call to fail")
|
||||
} else if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("call not cut off by the timeout: %v", elapsed)
|
||||
}
|
||||
_ = c.Close()
|
||||
|
||||
// timeout_seconds=10: the same slow call completes.
|
||||
c2, err := New(ctx, "t", srv.URL, nil, false, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
got, err := c2.Call(ctx, "slow_call", nil)
|
||||
if err != nil || got != "done" {
|
||||
t.Fatalf("Call() = %q, %v", got, err)
|
||||
}
|
||||
_ = c2.Close()
|
||||
|
||||
// timeout_seconds=0: falls back to the 120s default (call completes).
|
||||
c3, err := New(ctx, "t", srv.URL, nil, false, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if got, err := c3.Call(ctx, "slow_call", nil); err != nil || got != "done" {
|
||||
t.Fatalf("default-timeout Call() = %q, %v", got, err)
|
||||
}
|
||||
_ = c3.Close()
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func connectMCP(ctx context.Context, m *db.MCPServer) (mcpClient, error) {
|
||||
return nil, fmt.Errorf("http transport is missing a URL")
|
||||
}
|
||||
// env map doubles as HTTP headers (e.g. Authorization).
|
||||
return mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env), m.Insecure)
|
||||
return mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env), m.Insecure, m.TimeoutSeconds)
|
||||
case "sse":
|
||||
if m.URL == "" {
|
||||
return nil, fmt.Errorf("sse transport is missing a URL")
|
||||
|
||||
@@ -55,7 +55,7 @@ func (s *Server) scopeSentryClient(ctx context.Context) (*mcphttp.Client, error)
|
||||
if m.URL == "" {
|
||||
return nil, fmt.Errorf("data source %s has no URL configured; configure it first", scopeSentryMCPName)
|
||||
}
|
||||
return mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env), m.Insecure)
|
||||
return mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env), m.Insecure, m.TimeoutSeconds)
|
||||
}
|
||||
|
||||
// envHasValue reports whether the env/header map has any non-empty value
|
||||
@@ -102,7 +102,7 @@ func (s *Server) syncSSStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if configured && m.Enabled {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
defer cancel()
|
||||
if cl, cerr := mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env), m.Insecure); cerr == nil {
|
||||
if cl, cerr := mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env), m.Insecure, m.TimeoutSeconds); cerr == nil {
|
||||
if _, terr := cl.Tools(ctx); terr == nil {
|
||||
resp["reachable"] = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: rea
|
||||
description: Reverse-engineer binaries and application packages (ELF, PE, Mach-O, APK, IPA, HAP, WASM, ZIP/JAR containers, JavaScript bundles) through the rea MCP server running on the host. Invoke when a task needs static analysis of a compiled or packaged target: embedded secrets, hardcoded endpoints, native library review, string and constant extraction, managed code structure, or deobfuscation.
|
||||
mcps: rea
|
||||
---
|
||||
|
||||
# rea: binary and application reverse engineering
|
||||
|
||||
The rea MCP server runs on the host machine, not inside this container. Its tools are named `mcp__rea__<tool>`. After this skill loads, their names appear as deferred tools: fetch each tool's schema with SearchExtraTools (for example `select:mcp__rea__open_binary`, or keywords like `rea strings`), then invoke it with ExecuteExtraTool. Use the tool that directly answers the question; do not enumerate the whole catalog.
|
||||
|
||||
## 1. File paths: container view vs host view
|
||||
|
||||
rea opens files on the HOST filesystem, not inside the container. Only files under the bind-mounted data directory are visible to both sides; the exact host prefix is deployment-specific (check the compose file, or ask the operator via task chat when unknown).
|
||||
|
||||
Rules:
|
||||
- Save or download every target file under the container data directory (for example `/app/data/tasks/<task_id>/rea/`) so both sides can see it. Files elsewhere in the container are invisible to rea.
|
||||
- Always pass the host-side path in rea tool parameters. If open_binary reports the path as missing, you passed a container path: retry with the host-side equivalent of the same mount.
|
||||
- Verify the file exists on your side (ls) before opening it with rea.
|
||||
|
||||
## 2. Session lifecycle
|
||||
|
||||
1. `open_binary` with `path` (host path). For native binaries (ELF, PE, Mach-O) set `provider_id: "ghidra"`; if the error lists candidate provider ids, retry with one of them. Packages and bundles (APK, IPA, HAP, zip, JAR, wasm, JavaScript) need no provider.
|
||||
2. Run analysis tools (`search_strings`, `list_strings`, `search_procedures`, `inspect_native_*`, `project_managed_application_graph`, family-specific graph tools, ...). Results are Evidence objects: they carry an evidence id, a normalized result, and a limitations section. Record anything task-relevant as facts in the knowledge graph and cite the evidence id in findings.
|
||||
3. `close_binary` when done. One open target per session; close before opening another. Never leave a binary open at the end of a run.
|
||||
|
||||
## 3. What to use it for (authorized targets only)
|
||||
|
||||
- Client-side artifacts fetched from an in-scope target: mobile apps (APK, IPA, HAP), desktop binaries, Electron app archives, obfuscated JavaScript bundles, wasm modules.
|
||||
- Embedded secrets and configuration: API keys, tokens, endpoints, debug flags, test accounts (`search_strings` with patterns like `key`, `token`, `secret`, `password`, `http`, base64 blobs).
|
||||
- Fingerprinting: sha256 and component inventories identify the exact build and its third-party libraries.
|
||||
- Deep dives: strings and constants, imports and exports, procedures, managed type graphs, native API usage.
|
||||
|
||||
## 4. Practical notes
|
||||
|
||||
- Prefer scoped queries (one string pattern, one procedure) over whole-binary deep passes; some analyses take minutes. If the server is configured with a short request timeout, budget accordingly.
|
||||
- Large results are truncated to the session output limit with spill to disk; narrow the query instead of paging through huge output.
|
||||
- If a call fails with `capability_unavailable`, read the message: it usually lists the exact candidate provider ids or the remediation to apply.
|
||||
- rea reads files only; it never executes the target.
|
||||
- rea cannot see container-only paths; do not try to open files outside the shared data mount.
|
||||
@@ -32,6 +32,7 @@ type FormState = {
|
||||
url: string;
|
||||
env: string;
|
||||
insecure: boolean;
|
||||
timeoutSeconds: string;
|
||||
};
|
||||
const emptyForm: FormState = {
|
||||
name: "",
|
||||
@@ -41,6 +42,7 @@ const emptyForm: FormState = {
|
||||
url: "",
|
||||
env: "",
|
||||
insecure: false,
|
||||
timeoutSeconds: "",
|
||||
};
|
||||
|
||||
export default function MCPPage() {
|
||||
@@ -117,6 +119,7 @@ export default function MCPPage() {
|
||||
url: s.url ?? "",
|
||||
env: envToText(s.env),
|
||||
insecure: s.insecure ?? false,
|
||||
timeoutSeconds: s.timeout_seconds ? String(s.timeout_seconds) : "",
|
||||
});
|
||||
setTab("config");
|
||||
setOpen(true);
|
||||
@@ -158,6 +161,9 @@ export default function MCPPage() {
|
||||
args: [] as string[],
|
||||
env: parseEnv(form.env), // in remote mode env holds request headers
|
||||
insecure: form.insecure,
|
||||
...(Number.isFinite(Number(form.timeoutSeconds)) && Number(form.timeoutSeconds) > 0
|
||||
? { timeout_seconds: Number(form.timeoutSeconds) }
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
transport: "stdio" as const,
|
||||
@@ -306,6 +312,15 @@ export default function MCPPage() {
|
||||
/>
|
||||
Skip TLS certificate verification (self-signed certs)
|
||||
</label>
|
||||
<Label htmlFor="m-timeout">Request timeout (seconds, default 120)</Label>
|
||||
<Input
|
||||
id="m-timeout"
|
||||
type="number"
|
||||
min={5}
|
||||
placeholder="120"
|
||||
value={form.timeoutSeconds}
|
||||
onChange={(e) => setF({ timeoutSeconds: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
|
||||
@@ -1178,6 +1178,7 @@ export interface MCPServer {
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
insecure?: boolean; // http: skip TLS cert verification (self-signed servers)
|
||||
timeout_seconds?: number; // http/sse: per-request timeout in seconds (default 120)
|
||||
tools?: string[]; // mcp_tools_cache (names only, for the count)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user