MCP server exposing Empire operations as tools, plus an embedded LLM chat
operator wired into Empire's chat channel. Both surfaces (an external MCP
client, and the @empire-ai chat bot) share a single ToolRegistry — the
same tool implementations, the same audit trail, no divergent logic between
"a human's AI client drives Empire" and "the bot in chat drives Empire".
This is a standalone plugin repository (like impacket-plugin): it is
developed and versioned independently of Empire itself.
Requires Empire 7.0 or newer, on either install path below: the
@empire-ai chat bridge registers on Empire's AFTER_CHAT_MESSAGE_HOOK
(fired from the chat/message Socket.IO handler), which 7.0 provides.
The plugin runs in-process inside the Empire server, so its dependencies must be importable by Empire's own interpreter, however it gets installed.
- Install MCP Plugin via the Starkiller plugin marketplace (or
POST /api/v2/plugin-registries/marketplace/install). - Install its dependencies into Empire's own environment (Starkiller lists
them on the plugin page too):
cd /path/to/Empire && poetry add "mcp>=1.2,<2" "anthropic>=0.40" "openai>=1.50"
POST /api/v2/plugins/reload, or restart Empire.
Empire loads the plugin during step 1 — before its dependencies exist — so it
records a ModuleNotFoundError in load_error until step 3 re-imports it.
That is expected, not a sign anything is broken.
- Clone (or symlink) this repo to
empire/server/plugins/mcp_plugin/inside your Empire checkout. The directory name must be exactlymcp_plugin: Empire loads a plugin under a package named after its directory, so it has to matchplugin.yaml's slugifiedname— and namedmcp, this package's own relative imports would resolve against the PyPImcpSDK instead.git clone git@github.com:BC-SECURITY/Empire-MCP-Plugin.git \ /path/to/Empire/empire/server/plugins/mcp_plugin # or: ln -s /path/to/Empire-MCP-Plugin /path/to/Empire/empire/server/plugins/mcp_plugin - Install its dependencies into Empire's environment:
cd /path/to/Empire && poetry add "mcp>=1.2,<2" "anthropic>=0.40" "openai>=1.50"
- Start Empire. The plugin auto-loads but stays inert until you enable it (see Master switch below).
The plugin uses Empire's standard plugin enable/disable as its master switch —
there is no separate enabled setting. auto_start is false, so the plugin
loads but stays inert (nothing binds the MCP port, no chat hook is registered)
until you enable it:
PUT /api/v2/plugins/mcp_plugin {"enabled": true}
(or the enable toggle in the Starkiller plugin UI). Enabling runs the plugin's
on_start — which binds the MCP server and registers the chat hook; disabling
({"enabled": false}) runs on_stop for a clean teardown. Configure the
provider/credentials in Settings first, then enable.
Configure via the Starkiller plugin UI, or with
PUT /api/v2/plugins/mcp_plugin/settings (update_plugin_settings →
Plugin.set_settings). The body is a flat {"key": "value", ...} dict of
whichever settings you want to change. (Note the separate
PUT /api/v2/plugins/mcp_plugin endpoint carries {"enabled": true|false} — that is
the enable/disable master switch above, not a setting.)
| Key | Purpose | Default |
|---|---|---|
provider |
anthropic | openai_compatible |
anthropic |
model |
Model id passed to the provider. | claude-fable-5 |
api_key |
Provider API key. Stored like other Empire secrets (DB-backed plugin settings). | "" |
base_url |
Base URL for openai_compatible (OpenRouter, a self-hosted OpenAI-compatible endpoint, etc). Ignored for anthropic unless you need a proxy. |
"" |
stream |
Stream tokens from the provider. Set to False for endpoints that don't stream tool calls (some self-hosted proxies), to skip the wasted streaming attempt. |
True |
mcp_port |
Port the MCP server binds on loopback. | 2323 |
bot_handle |
Chat mention trigger. | @empire-ai |
system_prompt |
Operator persona / guardrails. Empty uses the built-in default (see chat_bridge._DEFAULT_SYSTEM_PROMPT). |
"" |
max_tool_iterations |
Cap on tool-call round-trips per chat reply (runaway-loop guard). | 10 |
history_window |
How many prior chat messages are folded into the prompt as context. | 20 |
While the plugin is enabled, changing any setting restarts the MCP server and
chat bridge cleanly (on_settings_change calls on_stop then on_start), so
edits take effect immediately without a full plugin reload. Editing settings
while disabled just stores them; they apply when you next enable the plugin.
Set provider to openai_compatible and point base_url at any
OpenAI-compatible server (OpenRouter, vLLM, Ollama, a self-hosted GLM/Qwen
proxy, etc.), e.g. base_url: https://your-host/openai, model: glm-5.2-fp8. The adapter streams tokens by default, but some self-hosted
proxies do not stream tool calls (they emit only empty chunks when tools
is set); the adapter detects an empty streamed turn and transparently retries
that turn once without streaming, so tool-calling works on those endpoints
too (you lose per-token streaming for that turn only). If you know an endpoint
doesn't stream tool calls, set the stream setting to False to skip the
wasted streaming attempt entirely — it applies to both providers.
Once enabled is true, the MCP server listens on loopback at:
http://127.0.0.1:2323/mcp/
Note the trailing slash. The route is mounted as a Starlette sub-application
at /mcp; a request to the bare path /mcp (no trailing slash) gets a 307
redirect to /mcp/. Most MCP clients follow redirects transparently, but if
yours does not (or logs the redirect as an error), point it at /mcp/
directly to skip the extra hop.
Example Claude Desktop claude_desktop_config.json entry (streamable-HTTP
transport):
{
"mcpServers": {
"empire": {
"url": "http://127.0.0.1:2323/mcp/"
}
}
}The server is loopback-only in v1 — there is no network exposure or
authentication on the MCP transport itself. Don't change mcp_port to bind a
non-loopback host without adding your own network-level access control in
front of it.
Any chat message containing the configured bot_handle (default
@empire-ai, case-insensitive substring match) is picked up by the chat
bridge:
-
A chat message is persisted and
AFTER_CHAT_MESSAGE_HOOKfires. -
ChatBridge.on_chat_messageruns synchronously on the firing thread. It does the minimum needed to decide whether to respond (ignores its own messages, ignores messages that don't mention it) and hands off to a new background worker thread — it never blocks the request that triggered it. -
The worker builds a transcript from the last
history_windowchat messages, calls the configured provider's agentic loop (run_agent_loop), and executes any tool calls the model requests against the sameToolRegistryan external MCP client would use. -
The worker persists the bot's final reply as a
ChatMessage(usernameempire-ai) and emitschat/stream/chat/messagesocketio events if a socketio server and event loop are attached (both degrade gracefully to a no-op otherwise — e.g. during startup beforesetup_socket_eventsruns, or in tests).chat/streamis forward-looking: no shipped Starkiller consumes it (Chat.vuehandles onlychat/join,chat/leave,chat/messageandchat/participants), so token-by-token streaming is not visible in the UI today.chat/messagecarries the final reply and matches Empire's own emit shape (username/message/timestamp).
The bot never replies to its own messages (loop prevention), and it responds
to the whole mention text, not a strict command grammar — phrase requests
in plain language, e.g. @empire-ai list the active agents and task the Windows one to run whoami.
There is no per-action confirmation gate. Every tool call the AI makes —
read-only or state-changing (task_shell, run_module,
create_listener/stop_listener, generate_stager, ...) — executes
immediately. This is a deliberate design choice, not an oversight:
-
A dedicated, disabled, non-loginable
empire-aiservice user exists purely so taskings andPluginTaskrows have something to attribute the bot's activity to, without adding a real auth principal. -
Tool calls are recorded as
PluginTaskrows (plugin_id="mcp_plugin", full input arguments, output, success/failure status) — whether they succeeded, failed, or targeted an unknown tool name. Visibility after the fact is the control, not a synchronous gate before the fact. Query these like any other plugin task history to reconstruct exactly what the AI did and when.The write is best-effort, not guaranteed.
ToolRegistry._safe_auditcatches every exception the audit write can raise, deliberately: a DB blip must never turn into a failure of the tool call itself, because callers need to be able to trustcall_tool's return value. The consequence is that a tool call — including a state-changing one — can execute with no row behind it. Those failures are logged (MCP audit write failed for tool <name>), so the server log is the backstop whenever a row is missing. -
Chat itself is the live feed: the bot's tool-driven reasoning and results are echoed back into the same channel it was addressed in.
The no-gate design assumes a trustworthy AI that might make mistakes, not an
adversarial one. A distinct threat remains: a compromised target host can
embed instructions in command/module output that the AI later ingests
(e.g. via a get_task_results-style read tool) and be steered by into
issuing destructive or exfiltrating tool calls — which then execute ungated,
and are only caught after the fact via the PluginTask audit trail.
Mitigation in place: all tool output is wrapped in explicit
<UNTRUSTED_TOOL_OUTPUT> fencing before being handed back to the model
(llm/agent_loop.py::fence_untrusted), with an instruction — both in that
fence and in the system prompt — to treat fenced content as data, never as
instructions to follow. This reduces but does not eliminate the risk: a
sufficiently crafted injection could still influence model behavior.
This residual risk is accepted, not solved, in exchange for the
ungated-autonomy model the plugin is built around. If it proves unacceptable
in a given environment, the natural mitigation — not implemented in v1 — is a
narrow allowlist/confirmation step scoped to the highest-impact tools
(task_shell, run_module) rather than a blanket gate on every call.
tests/(installed atempire/server/plugins/mcp_plugin/tests/) covers the registry, both provider adapters, the agent loop, the MCP server transport, the chat bridge, the plugin lifecycle, and an end-to-end integration test (test_integration.py::test_end_to_end_chat_triggers_tool) that drives the realon_chat_message → worker thread → run_agent_loop → registry.call_tool → persisted bot ChatMessagepath with a faked LLM provider. These are Empire integration tests — they boot the real app, so they run from within an Empire checkout (tests/conftest.pyre-exports Empire's shared fixtures). Run with./ps-empire test empire/server/plugins/mcp_plugin/tests -m "not llm".- Two tests are marked
@pytest.mark.llmand hit a real provider over the wire:test_live_anthropic_smoke(needsANTHROPIC_API_KEY) andtest_live_openai_compatible_smoke(needsOPENAI_COMPATIBLE_API_KEY+OPENAI_COMPATIBLE_BASE_URL, e.g. OpenRouter or a self-hosted OpenAI-compatible endpoint). Both no-op (skip) when the relevant environment variables are absent, so CI never needs live credentials.