MCP Server Discovery

Auto-discover MCP servers from Docker, Kubernetes, filesystem, or Python entrypoints.

Configuration

discovery:
  enabled: true
  refresh_interval_s: 30
  auto_register: true

  sources:
    - type: docker
      mode: additive

Sources

Docker/Podman

sources:
  - type: docker
    mode: additive
    socket_path: /var/run/docker.sock  # optional

Container labels:

# docker-compose.yml
services:
  my-mcp-server:
    image: my-mcp-server:latest
    labels:
      mcp.hangar.enabled: "true"
      mcp.hangar.name: "my-mcp-server"
      mcp.hangar.mode: "http"
      mcp.hangar.port: "8080"
LabelRequiredDefault
mcp.hangar.enabledyes-
mcp.hangar.namenocontainer name
mcp.hangar.modenohttp
mcp.hangar.portno8080
mcp.hangar.groupno-

Kubernetes

Needs the Kubernetes Python client, which is an extra — pip install mcp-hangar[kubernetes]. The published image ships it. Without it Hangar starts and logs discovery_source_unavailable, and this source discovers nothing.

sources:
  - type: kubernetes
    mode: authoritative
    namespaces: [mcp-servers]
    label_selector: "app.kubernetes.io/component=mcp-server"
    in_cluster: true
    allowed_namespaces: [mcp-servers]          # optional allowlist
    denied_namespaces: [kube-system, default]  # default

A pod in a denied namespace is refused registration even when it carries the annotations. denied_namespaces wins over allowed_namespaces; with no allowlist, everything not denied is accepted.

These two keys moved. They used to live under discovery.security, where the core applied them behind a check on the source's name. The old location still works and logs discovery_namespace_policy_deprecated_location on every startup that uses it; the new one wins when both are set.

Pod annotations:

apiVersion: v1
kind: Pod
metadata:
  annotations:
    mcp-hangar.io/enabled: "true"
    mcp-hangar.io/name: "data-processor"
    mcp-hangar.io/mode: "http"
    mcp-hangar.io/port: "8080"

Filesystem

sources:
  - type: filesystem
    mode: additive
    path: /etc/mcp-hangar/mcp_servers.d/
    pattern: "*.yaml"
    watch: true

MCP Server file:

# /etc/mcp-hangar/mcp_servers.d/custom.yaml
name: custom-tool
enabled: true
mode: subprocess
connection:
  command: python
  args: [-m, my_mcp_server]

Python Entrypoints

sources:
  - type: entrypoint
    mode: additive
    group: mcp.mcp_servers
# pyproject.toml
[project.entry-points."mcp.mcp_servers"]
my_mcp_server = "my_package.server:create_server"
def create_server():
    return {
        "name": "my-tools",
        "mode": "subprocess",
        "command": ["python", "-m", "my_package.server"]
    }

Adding a source of your own

Consul, Nomad, an internal registry — a source Hangar does not ship is a package of yours. Core is not modified and never learns your option names.

Implement the port — three members:

# my_package/consul_source.py
from mcp_hangar.domain.discovery.discovered_mcp_server import DiscoveredMcpServer
from mcp_hangar.domain.discovery.discovery_source import DiscoveryMode, DiscoverySource


class ConsulSource(DiscoverySource):
    def __init__(self, mode: DiscoveryMode, *, datacenter: str, token: str | None = None) -> None:
        super().__init__(mode=mode)
        self._datacenter = datacenter
        self._token = token

    @property
    def source_type(self) -> str:
        return "consul"

    async def discover(self) -> list[DiscoveredMcpServer]:
        ...  # return what is there now; the orchestrator diffs it for you

    async def health_check(self) -> bool:
        ...  # can this source be reached at all

Advertise a factory under the mcp_hangar.discovery_sources entry point group:

def create_source(mode: DiscoveryMode, config: dict) -> ConsulSource:
    return ConsulSource(mode, datacenter=config["datacenter"], token=config.get("token"))
# pyproject.toml
[project.entry-points."mcp_hangar.discovery_sources"]
consul = "my_package.consul_source:create_source"

Install the package next to Hangar and configure it like any built-in:

sources:
  - type: consul
    mode: additive
    datacenter: dc1
    token: ${CONSUL_TOKEN}

Hangar reads type and mode. Everything else in that entry is handed to your factory untouched, so datacenter and token are yours to name and yours to validate.

SituationWhat happens
type has no factoryStartup fails — a configured source that silently watches nothing is worse than a crash
Your package fails to importLogged as discovery_source_plugin_failed and skipped; the gateway still starts
Your entry point names a built-inLogged as discovery_source_plugin_ignored; a plugin cannot quietly shadow kubernetes

Refusing what it discovers

A source can veto its own findings in its own vocabulary. This runs before Hangar's checks (rate, count, health, schema), and Hangar never interprets it — the reason and details reach the operator and the quarantine report as written:

from mcp_hangar.domain.discovery.discovery_source import SourcePolicyViolation


def policy_violation(self, mcp_server: DiscoveredMcpServer) -> SourcePolicyViolation | None:
    datacenter = mcp_server.metadata.get("datacenter", "")
    if datacenter not in self._allowed:
        return SourcePolicyViolation(
            reason=f"Datacenter {datacenter!r} is not allowed",
            details={"datacenter": datacenter, "allowed": sorted(self._allowed)},
        )
    return None

The hook is optional. A source with no rules of its own overrides nothing — which is also why the Kubernetes namespace policy is the same mechanism rather than a special case in core.

Discovery Modes

ModeBehavior
additiveOnly adds MCP servers, never removes
authoritativeAdds and removes (for dynamic environments)

Security

discovery:
  security:
    max_mcp_servers_per_source: 100
    max_registration_rate: 10  # per minute
    require_health_check: true
    quarantine_on_failure: true

These apply to every source. Rules that only one kind of infrastructure understands — Kubernetes namespaces, and whatever a third-party source cares about — belong to that source; see Kubernetes and Refusing what it discovers.

A discovered server is registered through the same command as one created over the REST API, so it passes the same duplicate and SSRF checks, and the McpServerRegistered event carries source: discovery:<type>.

The SSRF check applies to discovered endpoints too, and a container or pod address is private by definition. A source that reports an HTTP endpoint on a private address is refused registration — see #771.

McpServerRegistered is not written to the event store. It is published to in-process subscribers only, so a server's registration does not appear in its stream — updates, lifecycle transitions and tool invocations do. Do not rely on the event history to answer "when was this server added, and by which source" — see #772.

Tools

ToolDescription
hangar_discoverTrigger discovery cycle
hangar_sourcesList sources with status
hangar_quarantineList quarantined MCP servers
hangar_approveApprove quarantined MCP server

Conflict Resolution

  1. Static config wins — Manual config always takes precedence
  2. Higher priority source wins — K8s (1) > Docker (2) > Filesystem (3) > Entrypoints (4) > any other source (99)
  3. TTL expiration — Authoritative sources deregister after TTL

Prometheus Metrics

MetricDescription
mcp_hangar_discovery_mcp_serversMCP servers per source (Gauge)
mcp_hangar_discovery_registrations_totalNew registrations
mcp_hangar_discovery_quarantine_totalRefused registrations, by reason — including a source's own policy
mcp_hangar_discovery_errors_totalErrors by source
mcp_hangar_discovery_cycle_duration_secondsCycle duration (Histogram)