diff --git a/code_puppy_core_plugins/mcp_prompts_resources/__init__.py b/code_puppy_core_plugins/mcp_prompts_resources/__init__.py new file mode 100644 index 0000000..227b8b5 --- /dev/null +++ b/code_puppy_core_plugins/mcp_prompts_resources/__init__.py @@ -0,0 +1 @@ +"""Expose MCP prompts and resources to the agent as tools.""" diff --git a/code_puppy_core_plugins/mcp_prompts_resources/register_callbacks.py b/code_puppy_core_plugins/mcp_prompts_resources/register_callbacks.py new file mode 100644 index 0000000..9f73b47 --- /dev/null +++ b/code_puppy_core_plugins/mcp_prompts_resources/register_callbacks.py @@ -0,0 +1,187 @@ +"""Expose MCP prompts and resources to the agent as tools. + +Code Puppy consumes MCP tools only. ``MCPToolset`` already implements +``list_prompts``/``get_prompt``/``list_resources``/``read_resource``; this +just surfaces them. + +Registered as agent tools rather than slash commands: tool calls run inside +the agent's async context, so the async MCP calls need no sync/async bridge. +""" + +from typing import Any, Dict, List, Optional + +from code_puppy.callbacks import register_callback +from code_puppy.mcp_.manager import get_mcp_manager +from code_puppy.mcp_.toolset_utils import toolset_is_running, unwrap_toolset + + +def _running_toolsets() -> List[tuple]: + """Yield ``(server_name, leaf_toolset)`` for every running MCP server.""" + out = [] + manager = get_mcp_manager() + for info in manager.list_servers(): + managed = manager.get_server(info.id) + if managed is None: + continue + try: + toolset = unwrap_toolset(managed.get_pydantic_server()) + except Exception: + # Disabled or quarantined servers raise; skip them. + continue + if toolset_is_running(toolset): + out.append((info.name, toolset)) + return out + + +def _supports(toolset: Any, field: str) -> bool: + """Whether the server advertised a capability. Unknown -> assume yes.""" + try: + caps = toolset.capabilities + except AttributeError: + return False + return bool(getattr(caps, field, True)) + + +def _text_of(content: Any) -> str: + """Best-effort text for a prompt message or resource payload.""" + for attr in ("text", "content", "data"): + value = getattr(content, attr, None) + if isinstance(value, str): + return value + if value is not None and not isinstance(value, (bytes, bytearray)): + inner = getattr(value, "text", None) + if isinstance(inner, str): + return inner + if isinstance(content, str): + return content + return repr(content) + + +def register_list_mcp_prompts(agent): + @agent.tool + async def list_mcp_prompts(context) -> str: + """List prompts offered by running MCP servers.""" + lines: List[str] = [] + for name, toolset in _running_toolsets(): + if not _supports(toolset, "prompts"): + continue + try: + prompts = await toolset.list_prompts() + except Exception as e: + lines.append(f"{name}: error listing prompts: {e}") + continue + for p in prompts: + args = ", ".join( + getattr(a, "name", "") + for a in (getattr(p, "arguments", None) or []) + ) + desc = getattr(p, "description", "") or "" + sig = f"({args})" if args else "()" + lines.append(f"{name}:{p.name}{sig} - {desc}".rstrip(" -")) + return "\n".join(lines) if lines else "No MCP prompts available." + + return list_mcp_prompts + + +def register_get_mcp_prompt(agent): + @agent.tool + async def get_mcp_prompt( + context, name: str, arguments: Optional[Dict[str, Any]] = None + ) -> str: + """Render an MCP prompt. ``name`` is ``server:prompt`` or just ``prompt``.""" + server_filter, _, prompt_name = name.rpartition(":") + errors: List[str] = [] + for server_name, toolset in _running_toolsets(): + if server_filter and server_name != server_filter: + continue + if not _supports(toolset, "prompts"): + continue + try: + result = await toolset.get_prompt(prompt_name, arguments or {}) + except Exception as e: + # Could be "wrong server" or genuinely bad arguments; keep the + # message so the latter doesn't masquerade as "not found". + errors.append(f"{server_name}: {e}") + continue + parts = [ + _text_of(getattr(m, "content", m)) + for m in (getattr(result, "messages", None) or []) + ] + return "\n\n".join(p for p in parts if p) or "(prompt returned no content)" + detail = f" ({'; '.join(errors)})" if errors else "" + return f"Prompt not found on any running MCP server: {name}{detail}" + + return get_mcp_prompt + + +def register_list_mcp_resources(agent): + @agent.tool + async def list_mcp_resources(context) -> str: + """List resources and resource templates on running MCP servers.""" + lines: List[str] = [] + for name, toolset in _running_toolsets(): + if not _supports(toolset, "resources"): + continue + try: + resources = await toolset.list_resources() + except Exception as e: + lines.append(f"{name}: error listing resources: {e}") + resources = [] + for r in resources: + desc = getattr(r, "description", "") or getattr(r, "name", "") or "" + lines.append(f"{name} {r.uri} - {desc}".rstrip(" -")) + try: + templates = await toolset.list_resource_templates() + except Exception: + templates = [] + for t in templates: + uri = getattr(t, "uriTemplate", None) or getattr(t, "uri_template", "") + desc = getattr(t, "description", "") or "" + lines.append(f"{name} {uri} (template) - {desc}".rstrip(" -")) + return "\n".join(lines) if lines else "No MCP resources available." + + return list_mcp_resources + + +def register_read_mcp_resource(agent): + @agent.tool + async def read_mcp_resource(context, uri: str) -> str: + """Read an MCP resource by URI.""" + errors: List[str] = [] + for name, toolset in _running_toolsets(): + if not _supports(toolset, "resources"): + continue + try: + contents = await toolset.read_resource(uri) + except Exception as e: + errors.append(f"{name}: {e}") + continue + if isinstance(contents, (list, tuple)): + body = "\n".join(_text_of(c) for c in contents) + else: + body = _text_of(contents) + return body or "(resource is empty)" + detail = f" ({'; '.join(errors)})" if errors else "" + return f"Could not read resource: {uri}{detail}" + + return read_mcp_resource + + +_TOOLS = { + "list_mcp_prompts": register_list_mcp_prompts, + "get_mcp_prompt": register_get_mcp_prompt, + "list_mcp_resources": register_list_mcp_resources, + "read_mcp_resource": register_read_mcp_resource, +} + + +def _register_tools() -> List[Dict[str, Any]]: + return [{"name": n, "register_func": f} for n, f in _TOOLS.items()] + + +def _register_agent_tools(agent_name=None) -> List[str]: + return list(_TOOLS) + + +register_callback("register_tools", _register_tools) +register_callback("register_agent_tools", _register_agent_tools) diff --git a/plugin-names.txt b/plugin-names.txt index a241a82..b247b66 100644 --- a/plugin-names.txt +++ b/plugin-names.txt @@ -30,6 +30,7 @@ hook_creator hook_manager logfire_oauth logfire_sessions +mcp_prompts_resources meta_oauth namespace_skill_search no_tools diff --git a/pyproject.toml b/pyproject.toml index a133358..721108a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ hook_creator = "code_puppy_core_plugins.hook_creator.register_callbacks" hook_manager = "code_puppy_core_plugins.hook_manager.register_callbacks" logfire_oauth = "code_puppy_core_plugins.logfire_oauth.register_callbacks" logfire_sessions = "code_puppy_core_plugins.logfire_sessions.register_callbacks" +mcp_prompts_resources = "code_puppy_core_plugins.mcp_prompts_resources.register_callbacks" meta_oauth = "code_puppy_core_plugins.meta_oauth.register_callbacks" namespace_skill_search = "code_puppy_core_plugins.namespace_skill_search.register_callbacks" no_tools = "code_puppy_core_plugins.no_tools.register_callbacks" diff --git a/tests/test_mcp_prompts_resources.py b/tests/test_mcp_prompts_resources.py new file mode 100644 index 0000000..e717aa6 --- /dev/null +++ b/tests/test_mcp_prompts_resources.py @@ -0,0 +1,241 @@ +"""Tests for the mcp_prompts_resources plugin.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from code_puppy_core_plugins.mcp_prompts_resources import register_callbacks as plugin + + +class FakeAgent: + """Captures the functions passed to ``@agent.tool``.""" + + def __init__(self): + self.fns = {} + + def tool(self, fn): + self.fns[fn.__name__] = fn + return fn + + +class FakeToolset: + def __init__( + self, + prompts=None, + resources=None, + templates=None, + body="body", + caps=None, + fail=None, + ): + self._prompts = prompts or [] + self._resources = resources or [] + self._templates = templates or [] + self._body = body + self._fail = fail or set() + self.capabilities = ( + caps if caps is not None else SimpleNamespace(prompts=True, resources=True) + ) + + async def list_prompts(self): + if "list_prompts" in self._fail: + raise RuntimeError("boom") + return self._prompts + + async def get_prompt(self, name, args): + known = {p.name for p in self._prompts} + if name not in known: + raise RuntimeError(f"Unknown prompt: {name}") + return SimpleNamespace( + messages=[SimpleNamespace(content=SimpleNamespace(text=f"rendered {name}"))] + ) + + async def list_resources(self): + if "list_resources" in self._fail: + raise RuntimeError("boom") + return self._resources + + async def list_resource_templates(self): + return self._templates + + async def read_resource(self, uri): + if uri != "docs://ok": + raise RuntimeError("Unknown resource") + return self._body + + +def _prompt(name, desc="", args=()): + return SimpleNamespace( + name=name, + description=desc, + arguments=[SimpleNamespace(name=a) for a in args], + ) + + +@pytest.fixture +def one_server(monkeypatch): + """Install a single running fake server and return it.""" + + def _install(toolset, name="platform"): + monkeypatch.setattr(plugin, "_running_toolsets", lambda: [(name, toolset)]) + return toolset + + return _install + + +def _fn(register, name): + agent = FakeAgent() + register(agent) + return agent.fns[name] + + +# ── registration contract ────────────────────────────────────── + + +def test_register_tools_contract(): + defs = plugin._register_tools() + assert {d["name"] for d in defs} == { + "list_mcp_prompts", + "get_mcp_prompt", + "list_mcp_resources", + "read_mcp_resource", + } + assert all(callable(d["register_func"]) for d in defs) + + +def test_tools_advertised_to_agents(): + assert set(plugin._register_agent_tools("code-puppy")) == set(plugin._TOOLS) + + +# ── capability gate ──────────────────────────────────────────── + + +def test_supports_false_when_not_connected(): + """A stopped toolset raises AttributeError; that must not propagate.""" + + class Stopped: + @property + def capabilities(self): + raise AttributeError("only available after initialization") + + assert plugin._supports(Stopped(), "prompts") is False + + +def test_supports_reads_advertised_flag(): + ts = FakeToolset(caps=SimpleNamespace(prompts=False, resources=True)) + assert plugin._supports(ts, "prompts") is False + assert plugin._supports(ts, "resources") is True + + +# ── prompts ──────────────────────────────────────────────────── + + +async def test_list_prompts_formats_signature(one_server): + one_server(FakeToolset(prompts=[_prompt("playbook", "Runbook.", ["topic"])])) + out = await _fn(plugin.register_list_mcp_prompts, "list_mcp_prompts")(None) + assert out == "platform:playbook(topic) - Runbook." + + +async def test_list_prompts_empty(one_server): + one_server(FakeToolset()) + out = await _fn(plugin.register_list_mcp_prompts, "list_mcp_prompts")(None) + assert out == "No MCP prompts available." + + +async def test_list_prompts_skips_servers_without_capability(one_server): + one_server( + FakeToolset( + prompts=[_prompt("p")], caps=SimpleNamespace(prompts=False, resources=True) + ) + ) + out = await _fn(plugin.register_list_mcp_prompts, "list_mcp_prompts")(None) + assert out == "No MCP prompts available." + + +async def test_list_prompts_reports_error(one_server): + one_server(FakeToolset(fail={"list_prompts"})) + out = await _fn(plugin.register_list_mcp_prompts, "list_mcp_prompts")(None) + assert "error listing prompts" in out + + +async def test_get_prompt_bare_and_qualified_name(one_server): + one_server(FakeToolset(prompts=[_prompt("playbook")])) + fn = _fn(plugin.register_get_mcp_prompt, "get_mcp_prompt") + assert await fn(None, "playbook", {}) == "rendered playbook" + assert await fn(None, "platform:playbook", {}) == "rendered playbook" + + +async def test_get_prompt_wrong_server_filtered_out(one_server): + one_server(FakeToolset(prompts=[_prompt("playbook")])) + fn = _fn(plugin.register_get_mcp_prompt, "get_mcp_prompt") + assert "not found" in await fn(None, "other:playbook", {}) + + +async def test_get_prompt_surfaces_failure_reason(one_server): + """A bad-arguments error must not masquerade as a plain 'not found'.""" + one_server(FakeToolset(prompts=[_prompt("playbook")])) + fn = _fn(plugin.register_get_mcp_prompt, "get_mcp_prompt") + out = await fn(None, "missing", {}) + assert "not found" in out and "Unknown prompt" in out + + +# ── resources ────────────────────────────────────────────────── + + +async def test_list_resources_includes_templates(one_server): + one_server( + FakeToolset( + resources=[SimpleNamespace(uri="docs://ok", description="Runbook")], + templates=[SimpleNamespace(uriTemplate="docs://{id}", description="By id")], + ) + ) + out = await _fn(plugin.register_list_mcp_resources, "list_mcp_resources")(None) + assert "platform docs://ok - Runbook" in out + assert "docs://{id} (template) - By id" in out + + +async def test_read_resource_returns_body(one_server): + one_server(FakeToolset(body="internal runbook")) + out = await _fn(plugin.register_read_mcp_resource, "read_mcp_resource")( + None, "docs://ok" + ) + assert out == "internal runbook" + + +async def test_read_resource_reports_error(one_server): + one_server(FakeToolset()) + out = await _fn(plugin.register_read_mcp_resource, "read_mcp_resource")( + None, "docs://nope" + ) + assert "Could not read resource" in out and "Unknown resource" in out + + +# ── server discovery ─────────────────────────────────────────── + + +def test_running_toolsets_skips_stopped(monkeypatch): + managed = SimpleNamespace(get_pydantic_server=lambda: "TS") + manager = SimpleNamespace( + list_servers=lambda: [SimpleNamespace(id="1", name="platform")], + get_server=lambda _id: managed, + ) + monkeypatch.setattr(plugin, "get_mcp_manager", lambda: manager) + monkeypatch.setattr(plugin, "unwrap_toolset", lambda t: t) + monkeypatch.setattr(plugin, "toolset_is_running", lambda t: False) + assert plugin._running_toolsets() == [] + + +def test_running_toolsets_skips_unavailable_server(monkeypatch): + """Disabled/quarantined servers raise from get_pydantic_server().""" + + def boom(): + raise RuntimeError("disabled") + + manager = SimpleNamespace( + list_servers=lambda: [SimpleNamespace(id="1", name="platform")], + get_server=lambda _id: SimpleNamespace(get_pydantic_server=boom), + ) + monkeypatch.setattr(plugin, "get_mcp_manager", lambda: manager) + assert plugin._running_toolsets() == []