Skip to main content
Glama

mcp-plugin-gateway

A plugin system where each plugin is a bare FastMCP server. The system discovers installed plugins, mounts them on an aggregate MCP server, and generates a matching command-line interface from the same tool schemas. A plugin author writes ordinary FastMCP code and declares one entry point; nothing else is required.

The distribution and command are named mcp-plugin-gateway. The Python import package is mcp_plugin_gateway, and plugins register in the mcp_plugin_gateway.plugins entry-point group.

The plugin contract

A plugin is a Python distribution that declares exactly one entry point in the mcp_plugin_gateway.plugins group. The entry point's value resolves to a zero-argument callable that returns a single FastMCP instance.

# pyproject.toml of the plugin distribution
[project]
name = "my-plugin"
version = "0.1.0"
dependencies = ["mcp-plugin-gateway", "fastmcp>=4.1.4"]

[project.entry-points."mcp_plugin_gateway.plugins"]
my-plugin = "my_plugin:build_server"
# src/my_plugin/__init__.py
from fastmcp import FastMCP


def build_server() -> FastMCP:
    server = FastMCP("my-plugin")

    @server.tool
    def add(a: int, b: int) -> int:
        """Add two integers."""
        return a + b

    return server

Install the distribution (pip install -e .) and the plugin is discovered. The callable is invoked lazily: importing the plugin's module and constructing its server happen when a surface first needs that plugin's components, not at discovery time. A plugin that needs configuration should therefore read it inside build_server() or inside its own lifespan, not at import time.

Create a plugin

From the root of your project, scaffold a standalone plugin distribution:

mcp-plugin-gateway new-plugin my-plugin

The command creates mcp-gateway/my-plugin/ with its metadata, README, and starter FastMCP server:

mcp-gateway/
└── my-plugin/
    ├── pyproject.toml
    ├── README.md
    └── src/
        └── my_plugin/
            └── __init__.py

The distribution name is the plugin's entry-point name; hyphens become underscores in the Python package name. From the generated project directory, install it into the same Python environment that runs the mcp-plugin-gateway console script:

cd mcp-gateway/my-plugin
python -m pip install -e .

Choose another project-relative destination with --directory:

mcp-plugin-gateway new-plugin my-plugin --directory plugins/my-plugin

Verify that the plugin and its starter hello tool are discovered, then call the tool. Its required name argument is a positional CLI argument:

mcp-plugin-gateway llm
mcp-plugin-gateway my-plugin hello Ada

To serve plugins for an MCP client, start the stdio server and configure the client to launch this command with its stdio transport:

mcp-plugin-gateway mcp

Add MCP to an existing Python project

If the current project has a PEP 621 pyproject.toml and exactly one top-level Python package under src/ (or the project root), initialize it in place:

mpg init

The command previews the changes to pyproject.toml and the new <package>/mcp_server.py, then asks for confirmation. Declining makes no changes. For a non-interactive run, review the printed plan and explicitly approve it with:

mpg init --yes

This registers the existing distribution as a plugin and adds a starter hello tool. It does not create a nested distribution or generate project-specific tools. An agent can adapt the starter server to expose the project's functionality. Unsupported or ambiguous layouts are rejected without changing files.

Naming rules

  • The entry-point name is the plugin name. It is used as the namespace on the aggregate server (my-plugin_add) and as the command group in the CLI.

  • Names must be unique across installed distributions. Two distributions declaring the same name is an error that names both distributions; uninstall or rename one.

  • The name should be lowercase and hyphenated, matching the distribution name. FastMCP normalizes tool names, so avoid characters that cannot appear in a tool name.

  • The names of the built-in commands are reserved: mcp, serve, llm, authoring, new-plugin, and config. A plugin that claims one of them is reported as a conflict and is not registered as a command group, so it cannot shadow the built-in command. Rename the plugin.

Contract rules

  • The callable must accept no arguments.

  • It must return a FastMCP instance. Returning anything else — or raising — is reported as a contract error naming the offending plugin and its distribution.

  • The returned server should be bare: no transport is started and no authentication is configured. The system owns transport and mounting.

  • An empty mcp_plugin_gateway.plugins group is valid and yields no plugins. Entry points in other groups are ignored.

Surface tags

Tools are exposed on two surfaces: MCP clients and the generated CLI. Tags decide which surface sees a tool.

Tags on the tool

MCP clients

CLI

(none)

visible

visible

{"mcp"}

visible

hidden

{"cli"}

hidden

visible

@server.tool
def shared(x: int) -> str:
    """Available on both surfaces."""
    return str(x)


@server.tool(tags={"mcp"})
def remote_only() -> str:
    """MCP clients only."""
    return "mcp"


@server.tool(tags={"cli"})
def local_only() -> str:
    """CLI only."""
    return "cli"

With the plugin above mounted as my-plugin, an MCP client lists my-plugin_shared and my-plugin_remote_only, while the CLI offers my-plugin shared and my-plugin local-only. Any other tag is rejected at registration with an error naming the plugin and the tool, so a typo cannot silently change visibility.

mcp-plugin-gateway authoring prints this contract as a generated document, so an agent reading it gets the current rules rather than a copy that can drift.

Related MCP server: basesmcp

Configuration

Configuration is per plugin and lazy. Each plugin gets its own settings class with an environment prefix derived from its name, so two plugins can use the same variable name without colliding. See the plugin's own documentation for the variables it reads.

Prefix convention

The prefix is the plugin name in upper case followed by an underscore, with - replaced by _. Plugin alpha reads ALPHA_*; plugin my-plugin reads MY_PLUGIN_*. A variable without a plugin's prefix is never read by that plugin.

# ~/.config/mcp_plugin_gateway/.env
ALPHA_API_KEY=alpha-key
BETA_API_KEY=beta-key

Local development

Local runs read a single user-level file, ~/.config/mcp_plugin_gateway/.env. The path is resolved through your home directory, so the file is genuinely read. A .env shipped inside a plugin's own directory is ignored.

mkdir -p ~/.config/mcp_plugin_gateway
printf 'ALPHA_API_KEY=alpha-key\n' > ~/.config/mcp_plugin_gateway/.env

Docker secrets

Set MCP_PLUGIN_GATEWAY_MODE=container to read settings from /run/secrets instead of the user config file. In this mode the .env file is not read at all, so secrets are authoritative. A missing /run/secrets directory only warns; the remaining sources still apply.

Secret file names must carry the plugin prefix in upper case. A file named ALPHA_TOKEN supplies plugin alpha's token setting; a file named alpha_token is ignored. Docker secret names are conventionally lower case, so declare the prefixed name explicitly:

services:
  alpha:
    environment:
      MCP_PLUGIN_GATEWAY_MODE: container
    secrets:
      - source: alpha_token
        target: ALPHA_TOKEN
secrets:
  alpha_token:
    file: ./secrets/alpha_token.txt

Precedence

Highest wins:

  1. Process environment

  2. .env file (local mode only)

  3. Secrets directory (container mode only)

  4. Declared defaults

Local and container modes never configure both file and secrets sources at once, because pydantic-settings silently prefers the file when both are present.

Build-time check

The container build verifies configuration so a missing variable fails the build rather than the first tool call. The check constructs every plugin's server without executing any tool:

MCP_PLUGIN_GATEWAY_MODE=container python -m mcp_plugin_gateway.settings

It prints one line per plugin and exits non-zero if any plugin fails to build.

Container image and Compose

The repository ships a Dockerfile and a docker-compose.yml. The image runs the build-time check above as a build step, so a missing secret fails docker build instead of the first tool call. The secret is mounted as a BuildKit secret, so it never lands in an image layer:

docker build \
  --secret id=SECURE_PLUGIN_TOKEN,src=./secrets/SECURE_PLUGIN_TOKEN \
  -t mcp-plugin-gateway:local .

Building without that secret fails on purpose and names the missing variable:

docker build .   # exits non-zero: "token ... Field required"

docker-compose.yml serves each plugin as its own MCP service, so a plugin whose settings are broken cannot take down the others. Every service runs the same image with a different --plugin argument and reads its secret from /run/secrets:

docker compose config   # validate
docker compose up -d    # start one container per plugin

The secret file name must be the plugin-prefixed variable name in upper case (SECURE_PLUGIN_TOKEN for plugin secure-plugin); a lowercase name is ignored. See examples/README.md for the two example plugins and how to call them.

Usage

mcp-plugin-gateway --help            # list discovered plugins
mcp-plugin-gateway my-plugin --help  # list that plugin's CLI-visible tools
mcp-plugin-gateway my-plugin add 1   # call a tool; defaulted params are options
mcp-plugin-gateway my-plugin add 1 --b 2

Commands are generated from each tool's input schema, so no CLI code is written by hand. The mapping is:

Schema

CLI

required parameter

positional argument

parameter with a default

named option (--limit 10)

enum

option validated against the choices

array

repeatable flag (--items a --items b)

boolean

paired flags (--loud/--no-loud)

nested object

single JSON option (--o '{"name": "x"}')

Underscores in parameter names become dashes: max_items is --max-items.

Every plugin's commands sit beneath a plugin-named group, even when the plugin has a single tool. mcp-plugin-gateway alpha ping stays valid when alpha gains a second tool.

A plugin whose settings are invalid does not break the CLI. It appears as a stub group whose invocation reports the error, so --help still lists healthy plugins.

Output format

Results print as human-readable text by default, matching what an MCP client sees. A tool that returns a string prints it verbatim, so markdown and tables reach the terminal unescaped; a scalar prints its bare value; a structured result prints as indented JSON.

Pass --json for the machine-readable envelope, either before the plugin name or after the tool name:

mcp-plugin-gateway my-plugin greet Ada          # Hello, Ada!
mcp-plugin-gateway my-plugin greet Ada --json   # {"result": "Hello, Ada!"}
mcp-plugin-gateway --json my-plugin greet Ada   # same

The flag is consumed by the CLI and never forwarded to the tool.

Shell completion

mcp-plugin-gateway --install-completion bash   # install for the current shell
mcp-plugin-gateway --show-completion bash      # print the script instead

Completion is dynamic: plugin names come from installed entry points and tool names from the selected plugin's CLI-visible tools. A plugin's mcp-tagged tools are not offered, because the CLI cannot call them.

Serving over MCP

mcp-plugin-gateway serve             # serve all plugins over stdio

Context for LLMs

Two commands print documents meant to be read by an agent rather than by a person. Both are generated at read time from live introspection, so neither can describe an install that no longer exists.

mcp-plugin-gateway llm               # the installed plugins and their tools
mcp-plugin-gateway authoring         # the contract a plugin must satisfy

llm describes the install: every installed plugin, its tools, each tool's parameters, and the surface each tool is reachable from. It is the authoritative answer to "what can I call here?", and it is generated from the same introspection the CLI and MCP surfaces are built from, so it cannot drift from them.

The Markdown view keeps parameter summaries concise while showing enum choices, nested object fields, array item types, and declared result shapes. The JSON view is lossless: each tool's parameters field contains its complete input schema, and output_schema contains its complete output schema or null when none is declared.

# Installed plugins

1 plugin(s) installed.

## my-plugin

- Distribution: `my-plugin`

### add

Add two integers.

- Surfaces: cli, mcp
- Parameters:
  - `a` (integer, required)
  - `b` (integer, required)

Narrow the document to one plugin with --plugin. An unknown name exits non-zero and prints no document, so a caller never has to tell "absent" from "broken":

mcp-plugin-gateway llm --plugin my-plugin

A plugin that cannot be constructed is listed with its failure reason instead of being dropped, and the healthy plugins are still described. One broken plugin never hides the rest.

authoring describes the contract: the entry-point group, the required callable shape, the surface tags, and a worked example. It is independent of what is installed, so it stays complete in an environment with no plugins at all. The example it shows is the same source the test suite installs and runs, so it cannot drift from what actually works.

Both commands print markdown by default and a JSON document on request, with --json accepted before or after the command name:

mcp-plugin-gateway llm --json         # {"plugins": [...]}
mcp-plugin-gateway authoring --json   # {"entry_point_group": "...", ...}

Standalone plugin distributions

A plugin distribution can be the only thing installed in an environment. It depends on mcp-plugin-gateway, declares its own mcp_plugin_gateway.plugins entry point, and ships the mcp-plugin-gateway console script as a transitive dependency. Users install one distribution and get both surfaces for its tools — no host project, no extra wiring.

# pyproject.toml of the standalone plugin distribution
[project]
name = "my-plugin"
version = "0.1.0"
dependencies = ["mcp-plugin-gateway", "fastmcp"]

[project.entry-points."mcp_plugin_gateway.plugins"]
my-plugin = "my_plugin:build_server"
pip install my-plugin
mcp-plugin-gateway --help        # lists the my-plugin group
mcp-plugin-gateway add 1 2       # plugin name omitted: only one plugin installed
mcp-plugin-gateway mcp           # serves my-plugin's tools without a prefix

Unqualified versus namespaced tool names

The plugin name is optional when exactly one plugin is installed. The surfaces resolve the lone plugin and drop its namespace:

Installed plugins

CLI

MCP tool names

one

mcp-plugin-gateway add 1 2

add, ping

two or more

mcp-plugin-gateway my-plugin add 1 2

my-plugin_add, other_ping

With two or more plugins installed the name is required. An unnamed invocation exits non-zero and lists the installed plugin names instead of guessing:

Ambiguous plugin selection: 2 plugins are installed (my-plugin, other-plugin).
Name one explicitly, for example 'mcp-plugin-gateway my-plugin ...'.

The explicit form always works, in both environments, so an invocation written for a multi-plugin environment keeps working when it is the only plugin:

mcp-plugin-gateway my-plugin add 1 2   # same result as the unnamed form
mcp-plugin-gateway mcp --plugin my-plugin   # serve only this plugin, unqualified

mcp-plugin-gateway mcp serves the aggregate server — namespaced tool names — only when two or more plugins are installed. Naming a plugin with --plugin always serves that plugin unqualified.

Entry points are read from the active interpreter's installed metadata. A plugin installed into a different virtual environment is invisible, so install the plugin into the environment that runs the console script.

Development

uv pip install -e . --python .venv/bin/python
uv pip install --group dev --python .venv/bin/python
.venv/bin/python -m pytest

The example plugins under examples/ are separate distributions. Install them to see the surfaces in action; see examples/README.md.

Related MCP Connectors

Related MCP Servers

  • F
    license
    B
    quality
    D
    maintenance
    A Model Context Protocol server framework featuring dynamic tool loading and automatic tool discovery from a dedicated directory. It leverages FastMCP to provide a robust environment for building, configuring, and testing individual Python-based tools.
    1
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables turning any Python function into an auto-registered MCP tool and serving them over a FastMCP server with auto-discovery, optional auth, and request context.
    MIT