diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 0795cff..c53d48c 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -26,6 +26,19 @@ # [networking] # ssrf_assume_public_cidrs = ["198.18.0.0/15"] +# MCP (Model Context Protocol) servers exposed as agent tools. +# Defining a server opts it in; tools appear as mcp____ with +# LOCAL tags (confirm flow applies). Names in disable stay disconnected. +# [mcp.servers.docs] +# command = "npx" +# args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] +# env = { RUST_LOG = "info" } +# cwd = "/path" # spawn working directory (empty = inherit) +# timeout = 30.0 # per-request timeout (seconds) +# read_only = true # mark every tool READ_ONLY (eligible for /btw --tools=read) +# [mcp] +# disable = ["docs"] # defined but never connected + [agent] # Persona (omit → built-in coding-agent line). tool_directives is the tool playbook. # Override with multi-line literals (prefer ''' so nested " is fine): diff --git a/src/plyngent/config/__init__.py b/src/plyngent/config/__init__.py index e05614f..3ed97f8 100644 --- a/src/plyngent/config/__init__.py +++ b/src/plyngent/config/__init__.py @@ -13,6 +13,8 @@ from .models import DatabaseConfig as DatabaseConfig from .models import DeepSeekConvention as DeepSeekConvention from .models import DeepseekProvider as DeepseekProvider from .models import HttpTimeoutConfig as HttpTimeoutConfig +from .models import McpConfig as McpConfig +from .models import McpServerConfig as McpServerConfig from .models import ModelConfig as ModelConfig from .models import NetworkingConfig as NetworkingConfig from .models import OpenAICompatibleProvider as OpenAICompatibleProvider diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 975253f..8457e30 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -143,6 +143,44 @@ class PluginsConfig(Struct, omit_defaults=True): disable: list[str] = field(default_factory=list) +class McpServerConfig(Struct, omit_defaults=True): + """One MCP server definition (stdio transport today). + + TOML example:: + + [mcp.servers.docs] + command = "npx" + args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + env = { RUST_LOG = "info" } + # cwd = "/path" # spawn working directory (empty = inherit) + # timeout = 30.0 # per-request timeout in seconds + # read_only = true # mark every tool READ_ONLY (safe side turns) + + ``url`` is reserved for the streamable-HTTP transport; empty = stdio. + A defined server is connected unless its name appears in ``[mcp].disable``. + """ + + command: str = "" + args: list[str] = field(default_factory=list) + env: dict[str, str] = field(default_factory=dict) + cwd: str = "" + timeout: float = 30.0 + read_only: bool = False + url: str = "" + + +class McpConfig(Struct, omit_defaults=True): + """Model Context Protocol servers exposed as agent tools. + + Defining a server under ``[mcp.servers.]`` is the opt-in; tools are + namespaced ``mcp____`` and registered with LOCAL tags. Names + in ``disable`` stay disconnected even when defined. + """ + + servers: dict[str, McpServerConfig] = field(default_factory=dict) + disable: list[str] = field(default_factory=list) + + class NetworkingConfig(Struct, omit_defaults=True): """Host-side network policy for tools such as ``fetch``. diff --git a/src/plyngent/config/store.py b/src/plyngent/config/store.py index 78f07f7..26c6224 100644 --- a/src/plyngent/config/store.py +++ b/src/plyngent/config/store.py @@ -9,6 +9,7 @@ import tomlkit from .models import ( AgentConfig, DatabaseConfig, + McpConfig, ModelConfig, NetworkingConfig, PluginsConfig, @@ -56,6 +57,29 @@ def _parse_networking(raw: dict[str, object]) -> NetworkingConfig: return NetworkingConfig() +def _parse_mcp(raw: dict[str, object]) -> McpConfig: + """Parse the ``[mcp]`` section, falling back to defaults. + + Unknown keys (typos like ``cmds``) are rejected the same way as providers: + a malformed section silently degrades to "no MCP servers" instead of a + half-parsed one. + """ + try: + config = msgspec.convert(raw, McpConfig) + except msgspec.ValidationError: + return McpConfig() + known = {"servers", "disable"} + if set(raw) - known: + return McpConfig() + known_server = {"command", "args", "env", "cwd", "timeout", "read_only", "url"} + servers_raw = cast("dict[str, object]", raw.get("servers", {})) + for name, raw_entry in servers_raw.items(): + entry = cast("dict[str, object]", raw_entry) + if not isinstance(raw_entry, dict) or not name.strip() or set(entry) - known_server: + return McpConfig() + return config + + def _parse_providers( document: tomlkit.TOMLDocument, ) -> tuple[dict[str, Provider], dict[str, object], dict[str, Provider]]: @@ -125,6 +149,7 @@ class ConfigStore: _agent: AgentConfig _plugins: PluginsConfig _networking: NetworkingConfig + _mcp: McpConfig _providers: dict[str, Provider] _bad_providers: dict[str, object] _recoverable_providers: dict[str, Provider] @@ -137,6 +162,7 @@ class ConfigStore: self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {}))) self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {}))) self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {}))) + self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {}))) self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document) @property @@ -187,6 +213,13 @@ class ConfigStore: """Typed networking section (e.g. Fake-IP SSRF exemptions).""" return self._networking + # -- mcp (read-only) -- + + @property + def mcp_config(self) -> McpConfig: + """Typed MCP section (server definitions + disable list).""" + return self._mcp + def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig: """Replace the plugin enable list (in-memory; call :meth:`write` to persist).""" cleaned = [name.strip() for name in names if name.strip()] @@ -390,6 +423,7 @@ class ConfigStore: self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {}))) self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {}))) self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {}))) + self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {}))) self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document) # -- internal sync helpers -- diff --git a/tests/test_config/test_mcp_section.py b/tests/test_config/test_mcp_section.py new file mode 100644 index 0000000..846b7a2 --- /dev/null +++ b/tests/test_config/test_mcp_section.py @@ -0,0 +1,66 @@ +"""[mcp] section parse: server definitions, disable gate, malformed fallback.""" + +from __future__ import annotations + +from plyngent.config import load + + +def test_mcp_defaults_and_parse(tmp_path) -> None: + path = tmp_path / "c.toml" + _ = path.write_text("", encoding="utf-8") + store = load(path) + assert store.mcp_config.servers == {} + assert store.mcp_config.disable == [] + + path.write_text( + """ +[mcp.servers.docs] +command = "npx" +args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] +env = { RUST_LOG = "info" } +timeout = 10.0 +read_only = true + +[mcp.servers.search] +command = "uvx" +args = ["mcp-server-fetch"] + +[mcp] +disable = ["search"] +""", + encoding="utf-8", + ) + store = load(path) + mcp = store.mcp_config + assert set(mcp.servers) == {"docs", "search"} + docs = mcp.servers["docs"] + assert docs.command == "npx" + assert docs.args == ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + assert docs.env == {"RUST_LOG": "info"} + assert docs.timeout == 10.0 + assert docs.read_only is True + assert docs.url == "" + assert mcp.disable == ["search"] + + +def test_mcp_malformed_section_falls_back_to_defaults(tmp_path) -> None: + path = tmp_path / "c.toml" + # Unknown section key. + path.write_text("[mcp]\nenabled = true\n", encoding="utf-8") + store = load(path) + assert store.mcp_config.servers == {} + + # Unknown server key (typo). + path.write_text('[mcp.servers.docs]\ncmds = "npx"\n', encoding="utf-8") + store = load(path) + assert store.mcp_config.servers == {} + + # Server entry is not a table. + path.write_text("[mcp]\nservers = 3\n", encoding="utf-8") + store = load(path) + assert store.mcp_config.servers == {} + + # Empty server name. + path.write_text('[mcp.servers.""]\ncommand = "npx"\n', encoding="utf-8") + store = load(path) + assert store.mcp_config.servers == {}