HexForge Gateway
Allows connecting to a local Ollama server for AI-powered workspace chat and analysis without requiring cloud services.
Provides OpenAI-backed completions for AI-assisted chat and analysis within the reverse-engineering workspace.
Optional persistent backend for storing workspaces and knowledge entries in Supabase, with automatic fallback to local file storage.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@HexForge GatewayDecompile app.apk with jadx and summarize its manifest and permissions"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
HexForge Gateway
AI-assisted APK reverse-engineering workspace API. Manages workspaces, dispatches tasks to MCP agents (jadx, apktool, adb, frida, filesystem, MT Manager's APK MCP, AI providers), and streams updates over WebSocket. Runs entirely on-device (Termux + MT Manager on Android) or on a normal PC - no cloud dependency required.
See HexForge_Architecture_v2.md for the original design doc this
project follows (Workspace -> Workflow -> Jobs -> Tasks -> MCP Agents,
modules communicating through an Event Bus) and HexForge_Documentation.md
for earlier context.
MIT licensed (LICENSE). Contributing: see CONTRIBUTING.md - the short
version is npm run typecheck && npm run build && ./scripts/smoke-test.sh
before opening a PR; .github/workflows/ci.yml runs the same checks
automatically, plus a handshake check on the MCP server frontend.
Stack
Runtime: Node.js 20+, TypeScript
Server: Fastify (+
@fastify/websocket)Validation: Zod
Persistence: local file storage by default (
STORAGE_BACKEND=local); Supabase optional, opt-in viaSTORAGE_BACKEND=supabaseAuth: opt-in API key check, off by default (
AUTH_ENABLED=false)
Related MCP server: Apktool MCP Server
Getting started
npm install
cp .env.example .env
npm run devServer starts on http://localhost:8080 by default. On start it prints a
banner with any existing workspaces and copy-pasteable commands to create
one. GET / returns the same information as JSON (live workspace list +
example commands) - the first thing worth hitting if you're new to this
repo and want to see what's here without reading further.
Core endpoints:
GET /— live cheat sheet: existing workspaces + example commandsGET /health— service + config status (storage backend, AI provider, auth)POST /workspaces— create a workspace ({ name, targetLabel })GET /workspaces/GET /workspaces/:id— list / fetchPUT /workspaces/by-name/:name— get-or-create by name ({ targetLabel? }) — idempotent, so you never have to copy a workspace id out of a response againPOST /workspaces/:id/tasks— dispatch a task to an MCP agent ({ agent, operation, payload? }) - fire-and-forget, pollGET /workspaces/:id/tasksor watch/wsfor the resultPOST /workspaces/:id/jobs— submit a retryable job (same body, plus optionalmaxAttempts) - see Job EnginePOST /workspaces/:id/workflows— submit a multi-step workflow - see Workflow EnginePOST /workspaces/:id/knowledge— create a knowledge entry - see Knowledge EnginePOST /workspaces/:id/chat/GET /workspaces/:id/chat— chat with the AI about a workspace - see AI Provider LayerGET /plugins— list plugins that loaded successfully this runWS /ws— real-timetask:update,job:update,workflow:update,knowledge:entry_created,workspace:status_changed
Full request/response shapes for each are documented in the section covering that feature, linked above.
Device requirements
Minimums, not comfort levels - the Gateway itself is a lightweight Node process, but jadx/apktool are JVM tools that can get memory- and CPU-hungry on large or heavily obfuscated APKs.
Software (either platform):
Node.js 20+
jadxandapktoolonPATHif you'll use those agents (both need a JVM -openjdk-17or similar)adbonPATHfor theadbagent (Android platform-tools)frida-tools(pip install frida-tools, needs Python) plus a matchingfrida-serveron the target device for thefridaagent - the version match between the two is the most common Frida failureapkid(pip install apkid, needs a yara-python build with DEX support) for theapkidagentcurl+jqforscripts/hf.shandscripts/smoke-test.sh
RAM: No hard minimum for the Gateway process alone - it's small. The real constraint is jadx/apktool decompiling a specific APK: small/simple apps are fine on modest hardware, large or obfuscated ones need noticeably more heap and time. Rarely a problem on a PC. On Android via Termux, a device with less than ~4GB total RAM will likely struggle on anything beyond small APKs - not a number that can be pinned down precisely, since it depends entirely on the APK.
Storage: The Gateway's own data (data/*.json, workspace/knowledge
metadata) stays tiny. The real space usage is WORKSPACES_ROOT, where
jadx/apktool output lands - decompiled source can run several times the
original APK's size. Budget per APK you work with, not once for the
whole project.
Android version (Termux): Android 7.0+ works with Termux installed via F-Droid or GitHub releases (the recommended install path - see "Running on Android" below). The Google Play Store build of Termux is discouraged and has reduced functionality; that build specifically needs Android 11+. Check Termux's own docs for anything more current, since app store requirements shift over time.
Network: None required at all with STORAGE_BACKEND=local (the
default) and AI_PROVIDER=ollama or openai-compatible pointed at a
local server - the Gateway can run fully offline. Network is only needed
for a cloud AI provider, STORAGE_BACKEND=supabase, or the
webhook-notifier plugin actually sending anything.
Auth
Off by default. core/auth.ts is an opt-in API key check - without it,
every route trusts whoever can reach the port: adb shell runs arbitrary
commands on a connected device, filesystem can write and delete files,
apktool/jadx can rebuild and resign APKs. Reasonable for a local,
single-user tool; not once the Gateway is reachable beyond localhost.
Turn it on:
AUTH_ENABLED=true
API_KEYS=some-long-random-key,another-key-for-a-second-deviceThen send the key as either header on every request:
curl -H "Authorization: Bearer some-long-random-key" http://localhost:8080/workspaces
# or
curl -H "X-API-Key: some-long-random-key" http://localhost:8080/workspacesGET /health is always exempt (so uptime checks work without a key) -
everything else, including GET / (which lists workspace names), is
gated once auth is on. Multiple comma-separated keys in API_KEYS let
different devices/people use their own, so you can revoke one without
rotating everyone's.
One safety net: AUTH_ENABLED=true with an empty API_KEYS doesn't lock
you out - it's treated as still-open, with a warning printed at startup,
rather than silently making every route unreachable including to you.
GET /health's authEnabled field only reports true once a real key
is configured, so you can confirm it's actually active rather than
assuming from the env var alone.
The CLI wrapper picks this up automatically - set HEXFORGE_API_KEY and
every hf/smoke-test.sh command sends it.
This is deliberately simple: one flat list of shared keys, no per-key
scopes, no expiry, no user accounts. Fine for a single operator's own
devices; if this project grows into something with actual multiple
untrusted users, core/auth.ts is the one file to replace, not extend.
Storage: local (default) or Supabase
STORAGE_BACKEND picks where workspaces and knowledge_entries live:
local(the default) — everything goes toproviders/local-storage.provider.ts, a JSON file per collection underDATA_DIR(default./data/). Writes are atomic (temp file + rename) so a killed process (common on mobile/Termux) can't corrupt it. No Supabase project, no network calls, no setup.SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEYare ignored entirely in this mode, even if they're set in.env.supabase— Supabase is primary, and a runtime failure (offline, DNS failure, outage) falls back to the same local file store automatically instead of crashing the request. Switch to this once there's an actual reason to share state across devices/users - e.g. once a web interface exists.
To use Supabase: create a project, run supabase.schema.sql in the SQL
editor, then set SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, and
STORAGE_BACKEND=supabase in .env. GET /health's storageBackend
field confirms which mode is actually active.
Worth knowing either way:
No sync between the two. Anything written to local storage while on
supabasemode during an outage stays local-only - checkdata/*.jsonfor anything written during the gap if that matters. Switchinglocal→supabaselater doesn't auto-migrate existing local data either.Not a real database. No indexing, no migrations, no query language
a flat
{ id: record }JSON map per collection. Concurrent writes within one process are safe (upsertRecord/deleteRecordserialize per collection so two requests racing on the same collection can't silently drop one's write) but that doesn't extend across multiple processes sharing oneDATA_DIR, which isn't a supported setup.
Jobs and Workflows are still in-memory only, regardless of
STORAGE_BACKEND- no local-storage or Supabase branch yet for those (see the note insupabase.schema.sql).
Falling back is logged ([workspace.service] Supabase ... failed, falling back to local storage: ...), so it's visible rather than silent.
AI Provider Layer
src/providers/ai.provider.ts exposes one function, complete(), backed
by nine interchangeable providers - Anthropic, Groq, Gemini,
Ollama, OpenAI, DeepSeek, xAI (Grok), Mistral, and a
generic openai-compatible option for anything else that speaks the
same shape (LM Studio, llama.cpp's server, vLLM, text-generation-webui,
OpenRouter, ...). Six of these - Groq, OpenAI, DeepSeek, xAI, Mistral,
and openai-compatible - share one implementation internally
(completeWithOpenAiCompatibleShape) since they're all the OpenAI Chat
Completions request/response shape against a different base URL; only
Anthropic, Gemini, and Ollama needed their own code. Adding a new
provider that speaks this shape is a ~10-line addition - see
CONTRIBUTING.md.
AI_PROVIDER=anthropic # or "groq", "gemini", "ollama", "openai", "deepseek", "xai", "mistral", "openai-compatible"
# Only the settings matching AI_PROVIDER are required - the rest can stay blank.
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-5 # optional, this is the default
GROQ_API_KEY=gsk_...
GROQ_MODEL=llama-3.3-70b-versatile # optional, this is the default
GEMINI_API_KEY=AIza...
GEMINI_MODEL=gemini-2.0-flash # optional, this is the default
OLLAMA_BASE_URL=http://localhost:11434 # optional, this is the default
OLLAMA_MODEL=llama3.3 # optional, this is the default
OLLAMA_API_KEY= # only needed for cloud models
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-5.4-mini # optional, this is the default
DEEPSEEK_API_KEY=sk-...
DEEPSEEK_MODEL=deepseek-v4-flash # optional, this is the default - note deepseek-chat/deepseek-reasoner (older docs' examples) were deprecated 2026-07-24
XAI_API_KEY=xai-...
XAI_MODEL=grok-4-6 # optional, this is the default
MISTRAL_API_KEY=...
MISTRAL_MODEL=mistral-large-latest # optional, this is the default - a Mistral-maintained alias, not a version that goes stale
OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1 # optional, LM Studio's default
OPENAI_COMPATIBLE_API_KEY= # most local servers don't need one
OPENAI_COMPATIBLE_MODEL= # required - no sensible default, depends what you're runningGroq and Gemini both have usable free tiers, unlike a fresh
Anthropic/OpenAI/DeepSeek/xAI/Mistral account which needs paid credits
first - handy for testing without billing setup. Ollama and
openai-compatible need zero API keys and zero external network
calls - everything runs on your own machine. Ollama is the simpler path
(install, ollama serve, ollama pull llama3.3) and also covers
Ollama's cloud models through the same endpoint - point OLLAMA_MODEL
at a -cloud-suffixed name (e.g. gpt-oss:120b-cloud) and set
OLLAMA_API_KEY, no separate provider needed. See
https://docs.ollama.com/cloud. openai-compatible is for anything else -
LM Studio, llama.cpp's server - point OPENAI_COMPATIBLE_BASE_URL at it
and set OPENAI_COMPATIBLE_MODEL.
GET /health reports aiProvider and aiConfigured. For Ollama,
aiConfigured just means "selected" - local mode has no key to check, so
the Gateway can't confirm the server is reachable without making a call.
Same for openai-compatible: it just means OPENAI_COMPATIBLE_MODEL is set.
Three things sit on top of the provider:
The ai MCP agent (modules/mcp/agents/ai.agent.ts) - a normal
agent (agent: "ai"), registered exactly like jadx/apktool. AI calls
made via a Job or Workflow step get retries and job.*/workflow.*
events for free. Operation: "summarize", payload { content, instructions? }.
curl -X POST http://localhost:8080/workspaces/<id>/jobs \
-H "Content-Type: application/json" \
-d '{"agent": "ai", "operation": "summarize", "payload": {"content": "..."}}'summarizeEntry() (modules/ai/ai.service.ts) - reads an existing
KnowledgeEntry, summarizes it, stores the result as a new "summary"
entry linked back via relatedEntryIds.
curl -X POST http://localhost:8080/knowledge/<entryId>/summarizeReturns 503 if the configured provider's key isn't set, 502 if the
provider's API call fails (e.g. Anthropic's "credit balance too low", or
an invalid key).
chat() (modules/ai/ai.service.ts) - real multi-turn conversation,
not a single-shot completion. CompletionRequest has a history field;
every provider builds its own multi-turn shape from it (Anthropic's
messages array, Gemini's contents with role: "model" instead of
"assistant" - the one real shape difference - and the OpenAI-style
{role, content} array everyone else uses). History loads from that
workspace's "chat"-type KnowledgeEntries on every call rather than
held in memory, so a conversation survives a Gateway restart.
History size is bounded three ways, not just a flat turn count - a flat
count alone doesn't actually control token cost, since a handful of long
messages (someone pasting a large summarize_text result, a decompiled
file) can blow past any reasonable count-based limit anyway:
Turn count: at most the most recent 20 turns are ever considered.
Per-message length: any single historical turn over ~4,000 characters is truncated (with a clear marker noting how much was cut) before being resent - without this, one huge message gets sent in full on every subsequent chat call for as long as it stays in the window, repeating that cost turn after turn.
Total character budget: history is walked newest-first and capped at a combined ~12,000 characters (a rough token budget), so a chat full of long messages naturally includes fewer old turns than one with short messages, rather than a fixed count regardless of size.
The budget walk moves in complete turn-pairs (a user message with its paired assistant reply), never splitting one - several providers (Anthropic in particular) reject a message array that starts or ends on the "wrong" role, so trimming mid-pair isn't just messier, it can break the request outright. The live message you're sending right now is never truncated by any of this - only past turns being resent as context are affected.
curl -X POST http://localhost:8080/workspaces/<id>/chat \
-H "Content-Type: application/json" \
-d '{"message": "What did the last jadx decompile find?"}'
curl http://localhost:8080/workspaces/<id>/chat # full transcript, oldest-firstOr from the terminal with hf chat - see CLI wrapper below.
Project layout
src/
index.ts entrypoint - startup banner, then app.listen()
core/
config.ts env validation (zod)
auth.ts opt-in API key check (off by default)
server.ts fastify app assembly, route registration
websocket.ts real-time update broadcasting
types.ts shared TypeScript types
api/v1/
health.routes.ts / root.routes.ts
workspace.routes.ts / job.routes.ts / workflow.routes.ts
knowledge.routes.ts (also owns the /chat routes)
plugin.routes.ts
modules/
workspace/
workspace.service.ts workspace CRUD (local storage or Supabase)
mcp/
orchestrator.ts task dispatch + agent handler registry
agents/ jadx, apktool, apkid, apkmcp, ai, filesystem, adb, frida
jobs/
job-engine.ts retryable wrapper around a single MCP task dispatch
workflow/
workflow-engine.ts sequential composition of several Jobs
knowledge/
knowledge.service.ts KnowledgeEntry CRUD
knowledge-indexer.ts auto-creates a report entry when a workflow finishes
ai/
ai.service.ts summarizeEntry() and chat() - ties the AI provider to the Knowledge Engine
providers/
supabase.client.ts provider-layer abstraction over Supabase
ai.provider.ts provider-layer abstraction over the AI vendor
local-storage.provider.ts JSON-file storage, primary in local mode / fallback in supabase mode
mcp-server/
index.ts stdio MCP server frontend - JSON-RPC loop, tool dispatch
tools.ts the ~19 MCP tools exposed, table-driven
gateway-client.ts thin HTTP client to an already-running Gateway
plugins/
types.ts PluginContext / HexForgePlugin contract
loader.ts discovers + safely loads plugins/installed/*
installed/
example-strings/ reference plugin - new MCP agent pattern
webhook-notifier/ reference plugin - event-only pattern
events/
event-bus.ts typed pub/sub singleton
types.ts EventMap - every event + payload shape
queues/ reserved - not yet built (Jobs/Workflows are in-memory, see Storage section)
scripts/
hf.sh CLI wrapper for manual testing
smoke-test.sh automated end-to-end test
.github/workflows/
ci.yml typecheck + build + real smoke-test.sh run + MCP handshake check, on every push/PR
LICENSE MIT
CONTRIBUTING.mdRoutes call into modules/* services directly (simple, synchronous
calls); those services publish to the Event Bus for anything
lifecycle-related, and things like the WebSocket gateway and the
Knowledge Indexer subscribe to those events rather than being called
directly. See Event Bus below.
Event Bus
src/events/event-bus.ts is a typed, in-process pub/sub singleton, per
HexForge_Architecture_v2.md's "modules communicate through events
rather than direct calls" principle. Every event and its payload shape is
defined in src/events/types.ts (EventMap) - add a new event by adding
a line there, then eventBus.emit(...) / eventBus.on(...) anywhere
with full type safety.
event | emitted by | payload |
| workspace service |
|
| workspace service |
|
| MCP orchestrator |
|
| Job Engine |
|
| Workflow Engine |
|
| Knowledge Engine |
|
| Plugin loader |
|
The WebSocket gateway (src/core/websocket.ts) subscribes to the events
it broadcasts - it has no direct reference to the orchestrator, Job
Engine, Workflow Engine, Knowledge Engine, or plugin loader. Same for the
Knowledge Indexer subscribing to workflow.*. This is the seam anything
new should plug into: subscribe to the events you care about, no direct
coupling to where they originate.
Implementation is deliberately a thin wrapper around Node's
EventEmitter (in-process, in-memory). If HexForge ever needs
cross-process delivery (multiple Gateway instances), a Redis-backed
pub/sub implementation can slot in behind the same emit/on/off
interface without touching any calling code.
Job Engine
src/modules/jobs/job-engine.ts wraps a single MCP task dispatch with
retry logic - the "Jobs" layer from HexForge_Architecture_v2.md's flow
(Workspace -> Workflow -> Jobs -> Tasks -> MCP Agents). The Workflow
Engine calls jobEngine.submit() the same way you can directly, just
composing several Jobs together for a multi-step run.
curl -X POST http://localhost:8080/workspaces/<workspaceId>/jobs \
-H "Content-Type: application/json" \
-d '{"agent": "jadx", "operation": "decompile", "payload": {"apkPath": "/path/to/app.apk"}, "maxAttempts": 3}'GET /workspaces/:id/jobs— list jobs for a workspaceGET /jobs/:jobId— fetch one job by id
A job creates a fresh underlying McpTask on each attempt. If that task
fails and attempts < maxAttempts, the Job Engine automatically
dispatches a new task and tries again; job.attempts and
job.currentTaskId track which attempt/task is live. Once attempts are
exhausted (or a task succeeds), the job reaches completed/failed and
publishes the matching event - /ws broadcasts every job.updated as
{"type": "job:update", "job": {...}}, so you can watch retries happen live.
Testing retries: point a job at something that will reliably fail
once (e.g. jadx with a deliberately wrong apkPath) with
maxAttempts: 1 first to confirm it fails cleanly, then bump
maxAttempts and watch attempts climb over /ws or repeated
GET /jobs/:jobId polls before it finally reports failed.
Workflow Engine
src/modules/workflow/workflow-engine.ts composes several Jobs into one
named operation - e.g. an "Analyze APK" workflow: decompile -> index
source -> AI summary. Steps run strictly sequentially; the Workflow
Engine calls jobEngine.submit() for one step at a time and only
dispatches the next once the current step's Job reaches a terminal
state. Retries within a step are already handled by the Job Engine via
that step's own maxAttempts - the Workflow Engine just reacts to
completed/failed, it doesn't re-implement retry logic.
curl -X POST http://localhost:8080/workspaces/<workspaceId>/workflows \
-H "Content-Type: application/json" \
-d '{
"name": "Analyze APK",
"steps": [
{ "agent": "jadx", "operation": "decompile", "payload": { "apkPath": "/absolute/path/to/app.apk" }, "maxAttempts": 2 },
{ "agent": "filesystem", "operation": "list", "mergePreviousResult": true }
]
}'GET /workspaces/:id/workflows— list workflows for a workspaceGET /workflows/:workflowId— fetch one, including per-step status/result/error
mergePreviousResult: true on a step shallow-merges the previous
step's result object into that step's payload before dispatch (result
keys win on conflict) - e.g. so a step after jadx:decompile can pick up
outputDir without the caller having to know it ahead of time. Omit it
if a step's payload is fully self-contained.
If any step's Job exhausts its maxAttempts and fails, the whole
workflow stops immediately and moves to failed with an error naming
which step failed - later steps are never dispatched. /ws broadcasts
every workflow.updated as {"type": "workflow:update", ...}, same as
jobs and tasks.
Knowledge Engine
src/modules/knowledge/ stores chats, reports, notes, and summaries as a
single KnowledgeEntry shape (type distinguishes them). Two parts:
knowledge.service.ts— plain CRUD (local storage or Supabase, perSTORAGE_BACKEND).knowledge-indexer.ts— a background listener (not a route) subscribing toworkflow.completed/workflow.failedand auto-creating a"report"entry summarizing every finished workflow run. The Workflow Engine never calls into the Knowledge Engine directly - pure Event Bus decoupling. Registered once at startup viaregisterKnowledgeIndexer()incore/server.ts.
relatedEntryIds links a generated "summary" back to what it
summarized (see summarizeEntry() above). embedding is still unused -
nothing in this project generates embeddings yet. "chat"-type entries
are written by ai.service.ts's chat() - one entry per turn, source: "user" vs source: "system" distinguishing who said it.
Endpoints:
POST /workspaces/:id/knowledge— create an entry ({ type, title, content, relatedEntryIds? })GET /workspaces/:id/knowledge?type=report— list, optionaltypefilterGET /knowledge/:entryId— fetch onePATCH /knowledge/:entryId/DELETE /knowledge/:entryId— update / deletePOST /knowledge/:entryId/summarize— see AI Provider Layer abovePOST /workspaces/:id/chat/GET /workspaces/:id/chat— see AI Provider Layer above
MCP agents
src/modules/mcp/orchestrator.ts registers a handler per agent kind.
jadx, apktool, apkid, apkmcp, filesystem, adb, and frida all
have real implementations - frida has the biggest asterisk on "real"
(see below). Dispatching a task (POST /workspaces/:id/tasks) is
fire-and-forget: it returns immediately with status: "queued", then
moves to running then completed/failed as the handler runs - poll
GET /workspaces/:id/tasks or watch /ws for the result, same as Jobs.
filesystem
For browsing/searching jadx/apktool output without leaving the API, or
general file management scoped to a workspace. Trust model:
list/read/stat/search accept any absolute path on the machine
(same as jadx/apktool already trust an arbitrary apkPath) - write
and delete are sandboxed to the workspace's own directory under
WORKSPACES_ROOT/<workspaceId>/, since those are destructive.
# search decompiled output for permission strings
curl -X POST http://localhost:8080/workspaces/<id>/tasks \
-H "Content-Type: application/json" \
-d '{"agent": "filesystem", "operation": "search", "payload": {"dirPath": "<jadx output dir>", "pattern": "android\\.permission\\.[A-Z_]+"}}'Operations: list (dirPath, recursive?, limit?), read (filePath,
encoding?: "utf8"|"base64"), write (filePath, content, encoding?
sandboxed),
delete(filePath- sandboxed),stat(filePath),search(dirPath,pattern- a regex,caseSensitive?,extensions?,maxResults?),scan-secrets(dirPath,extensions?,maxResults?) - same walk-and-match machinery assearch, but against a curated built-in set of high-precision patterns (AWS/Google API keys, private key headers, Slack/GitHub tokens, JWTs) instead of a user-supplied one. Deliberately not exhaustive - favors patterns distinctive enough to keep false positives low over generic ones likepassword=...that would flood results with test fixtures. Not a replacement for a maintained secret-scanner (gitleaks, trufflehog) on anything that actually matters.
search's pattern is user-supplied (and scan-secrets' built-in
patterns are still regexes) and regex engines can be tricked into
catastrophic backtracking (e.g. (a+)+ against a non-matching input can
hang effectively forever). Since a single synchronous RegExp.test()
call can't be interrupted once started, both run in a worker thread
(filesystem.search.worker.ts) with a 10s timeout - hitting it kills the
worker and returns a clear timeout error instead of a hung request.
adb
Requires adb on PATH. Talks to whatever device/emulator you already
have adb access to - deviceSerial is optional on every operation, only
needed with more than one device connected.
curl -X POST http://localhost:8080/workspaces/<id>/tasks \
-H "Content-Type: application/json" \
-d '{"agent": "adb", "operation": "install", "payload": {"apkPath": "<rebuilt apk>", "reinstall": true}}'Operations: devices, packages (filter?), install (apkPath,
reinstall?), uninstall (packageName, keepData?), shell
(command), logcat (filter?, lines? - dumps and tails the current
buffer rather than streaming live, matching this Gateway's
request/response model), pull (remotePath, fileName? - saved under
WORKSPACES_ROOT/<workspaceId>/adb/pulled/), push (localPath, remotePath).
shell runs whatever command you give it on the device - as powerful as
adb shell itself. Fine for a local, single-user tool; don't expose the
Gateway beyond localhost without turning on Auth first.
frida
Frida is fundamentally interactive/streaming - attach, inject, watch
hook events live for as long as you want. This Gateway is
request/response, so trace compromises the same way adb logcat does:
spawn or attach, inject a script, capture whatever it emits within a
bounded window, then kill it. For a real interactive session, use the
frida CLI directly - this agent is for "run this hook for N seconds and
tell me what happened."
Requires frida-tools on PATH and a frida-server running on the
target device, matching your frida-tools version - the single most
common Frida failure is a version mismatch between the two.
curl -X POST http://localhost:8080/workspaces/<id>/tasks \
-H "Content-Type: application/json" \
-d '{
"agent": "frida", "operation": "trace",
"payload": {
"target": "com.example.app",
"script": "Java.perform(function () { console.log(\"attached\"); });",
"timeoutSeconds": 15
}
}'Operations:
list-devices—frida-ls-deviceslist-processes(deviceSerial?,includeApps?) —frida-ps, optionally with installed (not just running) appspush-server(localServerPath,deviceSerial?,remotePath?) — adb-pushes afrida-serverbinary and chmods it executablestart-server/stop-server(deviceSerial?,remotePath?) — best-effort, requires root; backgrounding over a singleadb shellcall is fragile by nature, verify withlist-processesrather than trusting the response alonetrace(target,mode?: "spawn"|"attach",script,timeoutSeconds?- default 15, capped at 60) —targetis a package name inspawnmode, or a PID/process name inattachmode;scriptis raw Frida JS, written toWORKSPACES_ROOT/<id>/frida/scripts/before running
Timing out isn't a failure - it's the expected way trace ends, since a
script has no way to signal "I'm done" back to this agent. The
response's timedOut field tells you which happened; stdout/stderr
contain whatever the script emitted before the window closed either way.
Non-root devices need Frida's Gadget-based injection instead of a
frida-server push, which this agent doesn't implement.
jadx
Requires jadx on PATH. Produces readable Java-like source for browsing.
curl -X POST http://localhost:8080/workspaces/<workspaceId>/tasks \
-H "Content-Type: application/json" \
-d '{"agent": "jadx", "operation": "decompile", "payload": {"apkPath": "/absolute/path/to/app.apk"}}'Output goes to WORKSPACES_ROOT/<workspaceId>/jadx/; the result contains
the output directory and a capped file listing.
apktool
Requires apktool on PATH. Unlike jadx, decodes resources to
editable XML and disassembles code to smali - and can rebuild an APK
from a decoded project, which jadx cannot do. Use it to modify and
repackage an app, not just read it.
# decode
curl -X POST http://localhost:8080/workspaces/<workspaceId>/tasks \
-H "Content-Type: application/json" \
-d '{"agent": "apktool", "operation": "decode", "payload": {"apkPath": "/absolute/path/to/app.apk"}}'
# rebuild after editing the decoded project
curl -X POST http://localhost:8080/workspaces/<workspaceId>/tasks \
-H "Content-Type: application/json" \
-d '{"agent": "apktool", "operation": "build", "payload": {"inputDir": "<decode output dir, edited>", "outputName": "rebuilt.apk"}}'Decode output: WORKSPACES_ROOT/<workspaceId>/apktool/decode/. Optional
decode flags: noSrc: true (resources only, faster), noRes: true
(smali only). Build output:
WORKSPACES_ROOT/<workspaceId>/apktool/build/<outputName>.
Important: the rebuilt APK is unsigned and won't install until
signed - this project doesn't wire up signing for the apktool path. Use
apkmcp's build flow (signs automatically) for a signed result, or sign
manually with apksigner. Only rebuild and install apps you own or are
authorized to modify.
apkid
Requires apkid on PATH (pip install apkid - needs a yara-python
build with DEX support first, see
APKiD's install docs,
plain pip install yara-python isn't enough). Wraps
APKiD, a real, actively-maintained
YARA-rules-based fingerprinter for compilers, packers, obfuscators, and
anti-debug/anti-VM tricks - deliberately not reimplemented as a weaker
heuristic here, since APKiD's rules are maintained by people who actually
track new packers.
curl -X POST http://localhost:8080/workspaces/<id>/tasks \
-H "Content-Type: application/json" \
-d '{"agent": "apkid", "operation": "identify", "payload": {"apkPath": "/absolute/path/to/app.apk"}}'Operation: identify (apkPath, timeoutSeconds? - per-file YARA scan
timeout, default 30). Output is APKiD's own JSON passed through as-is,
not reshaped into a HexForge-specific structure - that schema belongs to
APKiD, not duplicated and drifted out of sync here.
apkmcp
A generic Model Context Protocol client with convenience operations
built for MT Manager's built-in "APK MCP" service (Android only - see
"Running on Android" below for setup). Default target
http://127.0.0.1:8787/mcp; override with baseUrl in the payload.
operation | payload fields | maps to |
| — |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| escape hatch for anything else |
curl -X POST http://localhost:8080/workspaces/<workspaceId>/tasks \
-H "Content-Type: application/json" \
-d '{"agent": "apkmcp", "operation": "open", "payload": {"path": "apks/app.apk", "temporary": false}}'
# grab the returned data.workspaceId, then:
curl -X POST http://localhost:8080/workspaces/<workspaceId>/tasks \
-H "Content-Type: application/json" \
-d '{"agent": "apkmcp", "operation": "list", "payload": {"workspaceId": "<mtWorkspaceId>", "view": "dex_classes"}}'Note: MT's path must be relative to its configured "MCP operation
directory" - absolute paths are rejected. Use list_available_apks if
unsure of the relative path.
mt_apk_edit_* and mt_apk_build (which can modify and re-sign an APK)
aren't wrapped in a convenience operation - use call_tool directly, and
only point them at apps you own or are authorized to modify.
MCP Server Frontend
Everything above this point is HexForge speaking MCP as a client (to
MT Manager's APK MCP service). src/mcp-server/ is HexForge speaking MCP
as a server - a stdio-based MCP server any MCP client (Claude Desktop,
Claude Code, Cursor, etc.) can register directly, so its agents show up
as native tools instead of only being reachable through the REST API or
this repo's own CLI.
This matters because most comparable projects in this space are
MCP servers first - a client registers them and calls their tools
directly. HexForge wasn't originally built that way: it's an HTTP Gateway
with its own persistent Jobs/Workflows/Knowledge Engine, which none of
those single-purpose MCP servers have. src/mcp-server/ doesn't replace
that - it's a thin adapter in front of it. Every tool call becomes a real
HTTP request to an already-running Gateway (src/mcp-server/gateway-client.ts)
and reuses all of its actual logic - retries via the Job Engine, workspace
resolution, everything. This process doesn't start a Gateway itself; one
needs to already be running.
Setup:
npm run build # compiles src/mcp-server/ to dist/mcp-server/ same as everything elseThen point your MCP client's config at it. For Claude Desktop
(claude_desktop_config.json):
{
"mcpServers": {
"hexforge": {
"command": "node",
"args": ["/absolute/path/to/hexforge-gateway/dist/mcp-server/index.js"],
"env": {
"HEXFORGE_URL": "http://localhost:8080"
}
}
}
}Add HEXFORGE_API_KEY to env too if the Gateway has AUTH_ENABLED=true.
The Gateway (npm run dev or npm start) needs to already be running
separately - this config only starts the thin MCP adapter, not the
Gateway itself.
Tools exposed (src/mcp-server/tools.ts) - a curated ~19, not a 1:1
mirror of every agent operation, picked for what's useful to drive
directly: list_workspaces, get_or_create_workspace,
list_knowledge, chat_with_workspace, decompile_apk, decode_apk,
build_apk, identify_packer, scan_secrets, search_code,
read_file, list_files, adb_devices, adb_shell, adb_install,
adb_logcat, frida_list_processes, frida_trace, summarize_text.
Every workspace-scoped tool takes an optional workspace name argument
(not an id) defaulting to "default", resolved through the Gateway's
get-or-create-by-name endpoint - the calling AI never needs to know or
track a workspace id.
Implementation notes, since a broken stdio MCP server tends to fail silently and confusingly rather than with a clear error:
Only
writeMessage()ever touches stdout. MCP's stdio transport is newline-delimited JSON-RPC - any strayconsole.log, a dependency that logs to stdout, anything at all besides a framed protocol message, corrupts the stream for the client reading it. Every log line in this server goes to stderr vialog(). Verified with a grep pass thatconsole.logdoesn't appear anywhere insrc/mcp-server/.Chunk-boundary buffering. Node delivers stdin in arbitrary chunks that don't line up with message boundaries - a single JSON-RPC message can arrive split across two
dataevents. The buffering logic (accumulate, split on\n, keep the last incomplete line for next time) was stress-tested against messages deliberately split mid-JSON across chunk boundaries before trusting it.Tool errors vs protocol errors. A tool that fails (bad path, jadx not installed) returns a normal MCP result with
isError: trueinside it - not a JSON-RPC-level error. That distinction is deliberate: a JSON-RPC error means the call itself was malformed (a client bug);isError: truemeans the tool ran and didn't work, which the model needs to see to react to (try a different path, ask the user to install something) rather than have swallowed as an opaque protocol failure.Every tool call blocks until its job settles (or a 2-minute poll timeout), rather than returning
"queued"and making the caller check back - MCP tool calls are expected to behave like a normal function call that returns a real result, so the waiting happens insidegateway-client.ts'srunJob(), not pushed onto whoever's driving the client.Tool results are compact JSON, not pretty-printed. This text goes straight into an AI model's context on every tool call, not a terminal a human reads - the indentation/newlines a pretty-print adds are pure token overhead here (measured ~33% fewer characters compact vs pretty on a representative result). Worth remembering before "helpfully" adding
null, 2back for readability.
Plugin System
src/plugins/ - per HexForge_Architecture_v2.md's Plugin System
("Allows official and community extensions without changing the core").
A plugin is a folder under plugins/installed/<name>/index.ts that
default-exports a HexForgePlugin:
import type { HexForgePlugin } from "../../types.js";
const plugin: HexForgePlugin = {
name: "my-plugin",
version: "0.1.0",
register(ctx) {
ctx.registerAgent("my-agent", async (task) => ({ ok: true }));
ctx.on("job.completed", (payload) => ctx.log.info(`job ${payload.job.id} done`));
ctx.app.get("/plugins/my-plugin/ping", async () => "pong");
},
};
export default plugin;ctx (PluginContext, in plugins/types.ts) is the entire surface a
plugin gets - it never imports the orchestrator, Event Bus, or Fastify
app directly:
ctx.registerAgent(kind, handler)— adds a new MCP agent kind, usable in Tasks/Jobs/Workflows exactly like the built-insctx.on(event, listener)— subscribes to any Event Bus eventctx.app— the live Fastify instance, for plugin-owned routesctx.log— prefixed console logger ([plugin:<name>] ...)
Because a plugin's agent kind isn't known ahead of time, the agent
field on Task/Job/Workflow-step request schemas is an open string, not a
fixed enum - a misspelled or unloaded agent still fails cleanly with
"No handler registered for agent ..." on that specific Task.
plugins/loader.ts discovers every folder under plugins/installed/ at
startup (after all core routes/indexers are registered) and calls each
plugin's register(). One plugin throwing is logged and skipped - it
can't take down the Gateway or another plugin. Restrict which load with
PLUGINS_ENABLED=name-one,name-two in .env (unset = load everything
found). GET /plugins lists what actually loaded; plugin.loaded /
plugin.failed fire on the Event Bus either way.
Two working reference plugins ship in plugins/installed/:
example-strings/— adds astringsMCP agent (runs thestringsutility on any file), subscribes tojob.completedto log its own runs, and adds aGET /plugins/example-strings/inforoute. Demonstrates the "new agent" pattern.curl -X POST http://localhost:8080/workspaces/<id>/jobs \ -H "Content-Type: application/json" \ -d '{"agent": "strings", "operation": "extract", "payload": {"filePath": "/absolute/path/to/some/file"}}'webhook-notifier/— demonstrates the event-only pattern: no new agent or route. SetWEBHOOK_NOTIFIER_URLand it POSTs a small JSON body whenever a Job or Workflow completes or fails, so you don't have to poll a long-running decompile to know when it's done. Unset, it still loads successfully but stays idle. No retries if your endpoint is down.
Copy either folder as a starting point for a real plugin.
CLI wrapper (scripts/hf.sh)
For manual testing (curl from a terminal/Termux), copying generated ids
out of every response into the next command gets old fast. hf.sh wraps
the API and remembers the current workspace/job/workflow id in
~/.hexforge/state.env, so most commands need zero ids typed:
chmod +x scripts/hf.sh # once
alias hf=./scripts/hf.sh # optional, from the repo root
hf ws clite-analysis # get-or-create workspace "clite-analysis", becomes current
hf ws-list # see every workspace that exists on the server
hf job jadx decompile '{"apkPath": "/storage/emulated/0/MT2/apks/Clite Dialer_1.0.apk"}'
hf job-status # no id needed - uses the job just submitted
hf wf analyze '[{"agent":"jadx","operation":"decompile","payload":{"apkPath":"/path/app.apk"}}]'
hf wf-status # no id needed - uses the workflow just submitted
hf knowledge # list knowledge entries for the current workspace
hf chat # interactive terminal chat - see below
hf current # show what's currently selectedSet HEXFORGE_URL if the Gateway isn't on localhost:8080, and
HEXFORGE_API_KEY if AUTH_ENABLED=true. Requires curl and jq. Run
hf with no args to get oriented - prints the full command list plus
your current workspace and every workspace on the server, so a
first-time contributor never has to guess an id or read this README
before doing anything.
Terminal chat
hf ws clite-analysis
hf chatDrops into a REPL - type a message, get a reply, repeat; exit, quit,
or Ctrl+D to leave. Every message and reply is a real request to POST /workspaces/:id/chat (nothing client-side is faked), so the same
conversation is visible via hf chat-log or GET /workspaces/:id/chat
later, from a different terminal, or eventually a web UI.
Implementation note if you're extending hf.sh: chat input is genuinely
free-form text (quotes, backslashes, newlines - anything), unlike every
other command's inputs here which are controlled (ids, package names,
JSON typed carefully). chat builds its request body with
jq -n --arg, which JSON-encodes the string properly regardless of
content - naive string interpolation would break the moment someone
typed a quote mark. Reuse jq -n --arg for any new free-text command.
Automated smoke test (scripts/smoke-test.sh)
./scripts/smoke-test.shEnd-to-end pass over everything that doesn't need external Android
tooling: workspace get-or-create, the filesystem agent
(write/read/search/delete, plus confirming a write outside the
workspace dir is correctly refused), a Job, a Workflow (including
mergePreviousResult and confirming the Knowledge Indexer auto-created
a report entry), Knowledge Engine CRUD, AI (a summarize job plus a full
chat round-trip - skipped with a clear reason if unconfigured), and the
Plugin System (GET /plugins, plus actually running the
example-strings agent if strings is on PATH). Prints
[PASS]/[FAIL]/[SKIP] per check and a summary; exits non-zero on
any failure.
Leaves the workspace it creates in place afterward
(smoke-test-<timestamp>) rather than cleaning up, so you can inspect
real data with hf ws-id <id> / hf knowledge instead of it vanishing
when the script exits. Prints exact hf commands at the end for what it
can't test itself: jadx/apktool (need a real .apk), adb (needs a
device/emulator), frida (needs frida-tools + frida-server), apkmcp
(needs MT Manager running on Android).
Same env vars as hf.sh.
Running on PC (Windows/Mac/Linux)
Nothing about the Gateway itself is Android/Termux-specific except the MT Manager integration.
cp .env.example .env
npm install
npm run devInstall jadx/apktool the normal way for your OS (brew install jadx
on macOS, download-and-extract on Windows/Linux from
jadx releases and
apktool.org/docs/install) - both just
need to be on PATH. Point the jadx/apktool agents at any local file
path directly - apkPath/inputDir accept an absolute path on your
machine, no MT Manager required:
curl -X POST http://localhost:8080/workspaces/<id>/jobs \
-H "Content-Type: application/json" \
-d '{"agent": "jadx", "operation": "decompile", "payload": {"apkPath": "C:/Users/you/Downloads/app.apk"}}'apkmcp doesn't apply here - it's built for MT Manager, an
Android-only app. There's no PC equivalent yet (a watched folder you
could drop APKs into instead of typing full paths - see Roadmap below);
for now, just use full paths.
Running on Android (Termux + MT Manager)
Runs entirely on-device - Gateway and MT Manager as two apps on the same phone, talking over loopback.
1. Termux basics
pkg update && pkg upgrade
pkg install nodejs git openjdk-17
termux-setup-storage # grants access to /storage/emulated/0/... for apkPath payloads2. jadx and apktool - neither ships in Termux's main repo, so install
manually: download from
jadx releases and
apktool's install page, extract, and
put the executables on PATH (e.g. symlink into $PREFIX/bin). Confirm
both work standalone (jadx --version, apktool --version) before
pointing the Gateway at them.
3. MT Manager's APK MCP - in MT Manager, find the APK MCP feature (under Settings/Tools - exact wording varies by version):
Set the MCP operation directory to wherever your APKs live (e.g.
/storage/emulated/0/MT2/apks) - MT only accepts paths relative to this directory.Start the service - defaults to
http://127.0.0.1:8787/mcp, matching this project's default. Since Termux and MT Manager run on the same device, loopback is directly reachable with no extra network setup.
Verify:
curl -X POST http://localhost:8080/workspaces/<id>/tasks \
-H "Content-Type: application/json" \
-d '{"agent": "apkmcp", "operation": "list_available_apks", "payload": {}}'If that fails, check MT's APK MCP service is actually running first - it doesn't always survive MT Manager being backgrounded.
4. Keeping the Gateway running - Android kills backgrounded Termux
sessions to save battery. Run termux-wake-lock before long sessions. A
killed process is exactly the scenario local-storage.provider.ts's
atomic writes are meant to survive cleanly - see
Storage above, and it's why local
storage is the default backend in the first place.
Roadmap
Everything else in this README - Event Bus, Job/Workflow Engines, Knowledge Engine, all eight MCP agents, Auth, nine AI providers, Terminal chat, the MCP Server Frontend, the Plugin System, local storage, CI - is built and documented in its own section above. What's genuinely still open:
Scope
/wsconnections per-workspace (currently broadcasts everything to every connection)Job/Workflow Supabase persistence (currently in-memory only regardless of
STORAGE_BACKEND- see the note insupabase.schema.sql)Web interface (once this exists,
STORAGE_BACKEND=supabasebecomes worth turning back on for shared state)PC-side equivalent of MT Manager's APK MCP - a watched/drop folder for APKs instead of typing full paths every time
Streamable HTTP transport for the MCP Server Frontend (currently stdio only - fine for Claude Desktop/Code spawning it locally, not for a remote/networked MCP client)
Committed lockfile (
package-lock.json) - CI usesnpm installrather thannpm cibecause none exists yet, so builds aren't fully reproducibleReal unit/integration tests. CI now runs
scripts/smoke-test.shagainst a live instance on every push/PR, which is real coverage for the happy paths it exercises - but it's still one script asserting end-to-end outcomes, not a test suite covering edge cases, error paths, or anything that needs mocking (e.g. a provider API returning malformed JSON)Local storage's per-collection design means
listEntriesForWorkspacereads and parses the entireknowledge_entries.json(every type, every workspace) on every call, even when filtering to one workspace's chat history - fine at current scale, worth indexing or splitting per-workspace before it isn't
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.
No tool schema history has been recorded yet.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
MCP server for progressive tool usage at any scale (see https://klavis.ai)
MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.
MCP server for static security analysis of Android source code
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Related MCP Servers
- AlicenseBqualityBmaintenanceA Model Context Protocol server that connects to a custom JADX fork (JADX-AI) and enables local LLMs to interact with decompiled Android app code for live reverse engineering assistance.32764Apache 2.0
- AlicenseAqualityCmaintenanceAn MCP server that integrates with Apktool to provide live reverse engineering support for Android applications using Claude and other LLMs through the Model Context Protocol.16642Apache 2.0
- AlicenseAqualityBmaintenanceA minimal, secure MCP server for AI-assisted mobile development, enabling build, install, interact, and inspect Android/iOS apps.331477Apache 2.0
- AlicenseNot gradedqualityBmaintenanceMCP server for analyzing Android APK, DEX, or JAR files via a headless jadx engine, enabling LLM agents to query decompiled code, symbols, call graphs, and more.2GPL 3.0
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/boy-offi9-inc/hexforge-gateway'
If you have feedback or need assistance with the MCP directory API, please join our Discord server