Skip to content

MCP Server Injection

A Bernstein plugin can inject Model Context Protocol (MCP) server definitions into every spawned agent's MCP config. The provide_mcp_servers plugin hook lets your distribution ship — for example — a Postgres MCP server, an internal-knowledge-base MCP, a credentials vault MCP, or a filesystem MCP scoped to the agent's worktree, without asking each operator to edit bernstein.yaml.

This page covers the hook contract, the merge flow at spawn time, security considerations, and a worked example.


Why this hook is powerful (and dangerous)

A plugin that injects an MCP server effectively grants tool access to every agent in every run that loads the plugin. Servers run with the spawning user's privileges; agents discover and call them based on keywords and capabilities; a misbehaving server can corrupt the worktree or leak secrets. Treat the hook as a privileged extension point.


Hook signature

Defined in src/bernstein/plugins/hookspecs.py:546-566:

@hookspec
def provide_mcp_servers(self) -> list[dict[str, Any]] | None:
    """Return MCP server definitions to inject into agent configs."""

A plugin that raises is logged and skipped — it does not crash collection (plugins/manager.py:973-975).

Server-dict fields

Field Type Default Purpose
name str required Server key (auto-prefixed with the plugin name)
package str "" npm package installed via npx -y <package>
command str "npx" Executable override
args list[str] ["-y", <package>] Argument list override
env_required list[str] [] Required env-var names (forwarded into the agent's env)
capabilities list[str] [] Tags for capability-based routing
keywords list[str] [] Phrases in a task description that auto-attach the server

What gets injected & when

Plugin loading (process startup)
   ├── PluginManager._safe_call("provide_mcp_servers", ...)
   │     iterates registered plugins
   └── PluginManager.collect_plugin_mcp_servers(registry)      [manager.py:955]
         └── for each plugin with provide_mcp_servers:
               raw_servers = plugin.provide_mcp_servers()
               for raw in raw_servers:
                   entry = _mcp_entry_from_dict(raw)            [manager.py:70]
                   entries.append(entry)
               registry.register_plugin_servers(plugin_name,    [mcp_registry.py:302]
                                                 entries)
                  ↑ sets entry.plugin_name → namespaced_name
                    becomes "<plugin>__<name>"

Agent spawn (per task batch)
   ├── effective_mcp = base_mcp_config (from bernstein.yaml)    [spawner_core.py:1619]
   ├── if mcp_registry is not None:                             [spawner_core.py:1620]
   │     effective_mcp = registry.resolve_for_tasks(tasks,
   │                                                base_config=effective_mcp)
   │     ↑ keywords + capabilities decide which servers attach
   └── if mcp_manager is not None:                              [spawner_core.py:1624]
         effective_mcp = mcp_manager.build_mcp_config_for_task(
             task_mcp_servers=task.mcp_servers,
             base_config=effective_mcp,
         )
         ↑ task-requested servers (task.mcp_servers field) are
           layered on top of plugin and base config

       validate_mcp_readiness(...)                              [spawner_core.py:1648]
       spawn the agent with the merged mcpServers JSON

The merge precedence (highest first): task.mcp_serversbase_config from bernstein.yaml and ~/.claude/mcp.json → auto-detected (plugin-provided + catalog) servers.

In the merged JSON, your plugin's server appears under mcpServers["<plugin_name>__<server_name>"].


Worked example

A minimal plugin that injects a filesystem MCP server scoped to a specific path.

acme_fs/__init__.py:

from bernstein.plugins import hookimpl


class AcmeFilesystemPlugin:
    """Inject a filesystem MCP server scoped to /workspace/shared."""

    @hookimpl
    def provide_mcp_servers(self) -> list[dict[str, object]]:
        return [
            {
                "name": "shared-fs",
                "package": "@modelcontextprotocol/server-filesystem",
                "args": [
                    "-y",
                    "@modelcontextprotocol/server-filesystem",
                    "/workspace/shared",  # least-privilege: one path
                ],
                "capabilities": ["filesystem", "read", "write"],
                "keywords": ["shared assets", "/workspace/shared"],
                "env_required": [],  # filesystem MCP needs no secrets
            }
        ]

acme_fs/pyproject.toml:

[project]
name = "acme-fs-plugin"
version = "0.1.0"

[project.entry-points."bernstein.plugins"]
acme = "acme_fs:AcmeFilesystemPlugin"

After pip install acme-fs-plugin, every agent spawn receives a merged config containing mcpServers["acme__shared-fs"] pointing at the filesystem MCP. A task whose description mentions "shared assets" gets the server auto-attached via MCPRegistry.detect_servers() keyword match even when the operator did not list it in task.mcp_servers.


Security considerations

  • Least privilege. Scope filesystem MCPs to the smallest path. Never pass / or the operator's home dir.
  • Env-var hygiene. List every secret your server consumes in env_required; the orchestrator copies only listed vars into the agent's env (MCPServerEntry.to_mcp_config()core/protocols/mcp/mcp_registry.py:79-86).
  • Fail closed. A raise in provide_mcp_servers() is logged and skipped (plugins/manager.py:973-975). A server that does not start is caught by validate_mcp_readiness() at spawn (spawner_core.py:1646-1654) — the spawn warns but does not crash.
  • Plugin policy gates registration. Bernstein's enterprise plugin policy (plugins_core.policy) can deny-list your plugin; MCP injection requires a registered, allowed plugin — there is no side-channel.
  • Document capabilities. An MCP server that can run shell commands is effectively an RCE vector for the LLM. Be explicit.

Testing your plugin

Unit test — namespacing

tests/unit/test_plugins.py:491-504 shows the established pattern:

def test_plugin_servers_are_namespaced():
    pm = PluginManager()
    pm.register(_MyPlugin(), name="acme")

    registry = MCPRegistry(config_path=None)
    pm.collect_plugin_mcp_servers(registry)

    config = registry.build_mcp_config(registry.servers)
    assert "acme__shared-fs" in config["mcpServers"]

Integration check — observe the merged config

Spawn one task in dry-run mode, then ripgrep the merged MCP config the spawner emitted into the worktree:

bernstein run --dry-run -t "use the shared fs"
rg --no-heading "acme__shared-fs" .sdd/worktrees/*/mcp.json

A match confirms hook return → registration → keyword resolution → spawn-time write all line up.


Code pointers

Concern File Symbol / line
Hook spec src/bernstein/plugins/hookspecs.py provide_mcp_servers:546-566
Plugin-side dispatch src/bernstein/plugins/manager.py collect_plugin_mcp_servers:955-975
Dict → entry conversion src/bernstein/plugins/manager.py _mcp_entry_from_dict:70-82
Per-plugin registration & namespacing src/bernstein/plugins/manager.py _register_plugin_mcp_servers:977-993
MCPServerEntry dataclass src/bernstein/core/protocols/mcp/mcp_registry.py MCPServerEntry:27-105
namespaced_name property src/bernstein/core/protocols/mcp/mcp_registry.py namespaced_name:53-63
register_plugin_servers src/bernstein/core/protocols/mcp/mcp_registry.py :302-336
Per-task config build src/bernstein/core/protocols/mcp/mcp_registry.py resolve_for_tasks:356-
Spawn-time merge & readiness probe src/bernstein/core/agents/spawner_core.py :1618-1654
Orchestrator-side registry construction src/bernstein/core/orchestration/orchestrator.py :4358-4364
Test suite tests/unit/test_plugins.py _MCPServerPlugin:480-504, collision test :507-522, error-isolation :525-542
  • integrations/plugin-sdk.md — full plugin authoring guide.
  • integrations/hook-system.md — wider hook lifecycle.
  • architecture/state-persistence.md — where the merged MCP config lands on disk.