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..64a51cd --- /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) }