From 24c8ea13c00a1c9b402dc82c9c65eda3360eb08a Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:02:23 +0200 Subject: [PATCH 1/2] 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 --- db/config.go | 37 +++++++------ db/schema.sql | 4 ++ mcphttp/client.go | 11 +++- mcphttp/client_test.go | 75 ++++++++++++++++++++++++++ server/mcpdiscover.go | 2 +- server/sync_scopesentry.go | 4 +- skills/rea/SKILL.md | 39 ++++++++++++++ web/src/app/(main)/system/mcp/page.tsx | 15 ++++++ web/src/lib/types.ts | 1 + 9 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 mcphttp/client_test.go create mode 100644 skills/rea/SKILL.md diff --git a/db/config.go b/db/config.go index f45d16d..ba75a7d 100644 --- a/db/config.go +++ b/db/config.go @@ -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 } diff --git a/db/schema.sql b/db/schema.sql index a481af3..acc2cb1 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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(); diff --git a/mcphttp/client.go b/mcphttp/client.go index 53e3f3e..fa6658b 100644 --- a/mcphttp/client.go +++ b/mcphttp/client.go @@ -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}} } diff --git a/mcphttp/client_test.go b/mcphttp/client_test.go new file mode 100644 index 0000000..cc6c50a --- /dev/null +++ b/mcphttp/client_test.go @@ -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() +} diff --git a/server/mcpdiscover.go b/server/mcpdiscover.go index 99c992b..b61545d 100644 --- a/server/mcpdiscover.go +++ b/server/mcpdiscover.go @@ -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") diff --git a/server/sync_scopesentry.go b/server/sync_scopesentry.go index e08b1e6..76372b5 100644 --- a/server/sync_scopesentry.go +++ b/server/sync_scopesentry.go @@ -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 } diff --git a/skills/rea/SKILL.md b/skills/rea/SKILL.md new file mode 100644 index 0000000..6de9a83 --- /dev/null +++ b/skills/rea/SKILL.md @@ -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__`. 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//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. diff --git a/web/src/app/(main)/system/mcp/page.tsx b/web/src/app/(main)/system/mcp/page.tsx index 2c7a37c..5dce909 100644 --- a/web/src/app/(main)/system/mcp/page.tsx +++ b/web/src/app/(main)/system/mcp/page.tsx @@ -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) + + setF({ timeoutSeconds: e.target.value })} + /> )}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 6c80edd..8acdb0a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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) } From 799defadd17afe4df87b2a8489fe6e47bb017f7d Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:09:08 +0200 Subject: [PATCH 2/2] fix: quote the rea skill description so the YAML frontmatter parses A plain YAML scalar cannot contain a colon followed by a space; the unquoted description had one, so yaml.Unmarshal failed and every frontmatter field (including mcps) stayed empty. The skill then loaded with its directory name but never gated its MCP server, and the server was silently skipped. --- skills/rea/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/rea/SKILL.md b/skills/rea/SKILL.md index 6de9a83..64a51cd 100644 --- a/skills/rea/SKILL.md +++ b/skills/rea/SKILL.md @@ -1,6 +1,6 @@ --- 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. +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 ---