diff --git a/src/plyngent/runtime/mcp_client.py b/src/plyngent/runtime/mcp_client.py index e89eba3..771370d 100644 --- a/src/plyngent/runtime/mcp_client.py +++ b/src/plyngent/runtime/mcp_client.py @@ -92,6 +92,8 @@ class McpServerConnection: error: str | None tools: list[McpTool] tools_stale: bool + # Server usage guidance from the initialize result (InitializeResult.instructions). + instructions: str | None _config: McpServerConfig _proc: asyncio.subprocess.Process | None @@ -111,6 +113,7 @@ class McpServerConnection: self.error = None self.tools = [] self.tools_stale = False + self.instructions = None @property def status(self) -> str: @@ -258,7 +261,7 @@ class McpServerConnection: self._send(payload) async def _initialize(self) -> None: - _ = await self._request( + result = await self._request( "initialize", { "protocolVersion": MCP_PROTOCOL_VERSION, @@ -266,6 +269,10 @@ class McpServerConnection: "clientInfo": {"name": MCP_CLIENT_NAME, "version": _client_version()}, }, ) + # Optional server usage guidance (MCP spec: InitializeResult.instructions). + # Hosts surface this to the model when the server's tools are in play. + instructions = result.get("instructions") + self.instructions = instructions.strip() if isinstance(instructions, str) and instructions.strip() else None await self._notify("notifications/initialized") async def list_tools(self) -> list[McpTool]: @@ -343,6 +350,21 @@ class McpManager: rows.append((name, connection.status, len(connection.tools))) return rows + def instructions(self) -> list[tuple[str, str]]: + """(server, text) initialize ``instructions`` for every connected server. + + Only non-empty guidance is returned, in config order. Hosts may fold + these into the agent system context (see cli.state.ReplState). + """ + rows: list[tuple[str, str]] = [] + for name in self.enabled_servers(): + connection = self._connections.get(name) + if connection is not None and connection.connected: + text = (connection.instructions or "").strip() + if text: + rows.append((name, text)) + return rows + def connections(self) -> list[McpServerConnection]: return list(self._connections.values()) diff --git a/tests/test_runtime/mcp_fake_server.py b/tests/test_runtime/mcp_fake_server.py index a5f0508..661a15b 100644 --- a/tests/test_runtime/mcp_fake_server.py +++ b/tests/test_runtime/mcp_fake_server.py @@ -1,10 +1,17 @@ -"""Minimal MCP stdio server used by the client tests (echo + sleep tools).""" +"""Minimal MCP stdio server used by the client tests (echo + sleep tools). + +Set ``PLYNGENT_MCP_FAKE_INSTRUCTIONS`` (env) to have the server return that +text as ``InitializeResult.instructions`` (usage guidance). +""" from __future__ import annotations import json +import os import sys +_FAKE_INSTRUCTIONS = os.environ.get("PLYNGENT_MCP_FAKE_INSTRUCTIONS", "") + def respond(request_id: int, result: object) -> None: sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n") @@ -22,14 +29,14 @@ def main() -> None: if request_id is None: continue # notification (notifications/initialized) if method == "initialize": - respond( - int(request_id), - { - "protocolVersion": "2025-06-18", - "capabilities": {"tools": {}}, - "serverInfo": {"name": "fake-mcp", "version": "0.1.0"}, - }, - ) + result: dict[str, object] = { + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "fake-mcp", "version": "0.1.0"}, + } + if _FAKE_INSTRUCTIONS: + result["instructions"] = _FAKE_INSTRUCTIONS + respond(int(request_id), result) elif method == "tools/list": respond( int(request_id), diff --git a/tests/test_runtime/test_mcp_client.py b/tests/test_runtime/test_mcp_client.py index b0451f1..aeb1525 100644 --- a/tests/test_runtime/test_mcp_client.py +++ b/tests/test_runtime/test_mcp_client.py @@ -32,6 +32,7 @@ async def test_connection_handshake_lists_and_calls_tools() -> None: try: assert connection.connected assert connection.error is None + assert connection.instructions is None assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow"] echo = connection.tools[0] assert echo.description == "Echo the given text back." @@ -43,6 +44,20 @@ async def test_connection_handshake_lists_and_calls_tools() -> None: await connection.aclose() +async def test_initialize_captures_server_instructions() -> None: + guidance = "Use echo to repeat text back to the user." + connection = McpServerConnection( + "fake", + _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}), + ) + await connection.start() + try: + assert connection.connected + assert connection.instructions == guidance + finally: + await connection.aclose() + + async def test_tool_error_returns_error_text() -> None: connection = McpServerConnection("fake", _server_config()) await connection.start() @@ -87,12 +102,31 @@ async def test_manager_start_status_and_call() -> None: await manager.ensure_started() try: assert [(name, status, count) for name, status, count in manager.statuses()] == [("fake", "connected", 3)] + assert manager.instructions() == [] assert await manager.call("fake", "echo", {"text": "hi"}) == "echo: hi" finally: await manager.aclose() assert manager.statuses() == [("fake", "not started", 0)] +async def test_manager_instructions_after_restart() -> None: + guidance = "Always answer with a haiku." + plain = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[])) + await plain.ensure_started() + assert plain.instructions() == [] + await plain.restart( + McpConfig( + servers={"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance})}, + disable=[], + ) + ) + await plain.ensure_started() + try: + assert plain.instructions() == [("fake", guidance)] + finally: + await plain.aclose() + + async def test_manager_disable_and_restart() -> None: manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=["fake"])) await manager.ensure_started()