Skip to content

Repository files navigation

empire-mcp-plugin

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.

Installation

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.

From the plugin registry (recommended)

  1. Install MCP Plugin via the Starkiller plugin marketplace (or POST /api/v2/plugin-registries/marketplace/install).
  2. 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"
  3. 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.

From a checkout (development)

  1. Clone (or symlink) this repo to empire/server/plugins/mcp_plugin/ inside your Empire checkout. The directory name must be exactly mcp_plugin: Empire loads a plugin under a package named after its directory, so it has to match plugin.yaml's slugified name — and named mcp, this package's own relative imports would resolve against the PyPI mcp SDK 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
  2. Install its dependencies into Empire's environment:
    cd /path/to/Empire && poetry add "mcp>=1.2,<2" "anthropic>=0.40" "openai>=1.50"
  3. Start Empire. The plugin auto-loads but stays inert until you enable it (see Master switch below).

Master switch: enable / disable the plugin

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.

Settings

Configure via the Starkiller plugin UI, or with PUT /api/v2/plugins/mcp_plugin/settings (update_plugin_settingsPlugin.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.

Self-hosted / OpenAI-compatible endpoints

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.

Connecting an external MCP client (e.g. Claude Desktop)

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.

The @empire-ai chat trigger

Any chat message containing the configured bot_handle (default @empire-ai, case-insensitive substring match) is picked up by the chat bridge:

  1. A chat message is persisted and AFTER_CHAT_MESSAGE_HOOK fires.

  2. ChatBridge.on_chat_message runs 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.

  3. The worker builds a transcript from the last history_window chat messages, calls the configured provider's agentic loop (run_agent_loop), and executes any tool calls the model requests against the same ToolRegistry an external MCP client would use.

  4. The worker persists the bot's final reply as a ChatMessage (username empire-ai) and emits chat/stream / chat/message socketio events if a socketio server and event loop are attached (both degrade gracefully to a no-op otherwise — e.g. during startup before setup_socket_events runs, or in tests).

    chat/stream is forward-looking: no shipped Starkiller consumes it (Chat.vue handles only chat/join, chat/leave, chat/message and chat/participants), so token-by-token streaming is not visible in the UI today. chat/message carries 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.

Safety posture: no approval gate, audit trail instead

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-ai service user exists purely so taskings and PluginTask rows have something to attribute the bot's activity to, without adding a real auth principal.

  • Tool calls are recorded as PluginTask rows (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_audit catches 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 trust call_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.

Accepted residual risk: indirect prompt injection

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.

Testing

  • tests/ (installed at empire/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 real on_chat_message → worker thread → run_agent_loop → registry.call_tool → persisted bot ChatMessage path 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.py re-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.llm and hit a real provider over the wire: test_live_anthropic_smoke (needs ANTHROPIC_API_KEY) and test_live_openai_compatible_smoke (needs OPENAI_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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages