Skill: ramonitor/silverbullet-skill (version 1.0.0)

--- name: silverbullet description: MCP server for SilverBullet note-taking app - read, write, search, and manage markdown pages homepage: https://silverbullet.md version: 1.0.0 metadata: clawdbot: requires: bins: ["python3", "uv"] install: - kind: script label: "Install SilverBullet MCP server" script: | cd "$SKILL_DIR" uv venv source .venv/bin/activate uv pip install -e . allowed-tools: "mcporter(silverbullet:*)" --- # SilverBullet MCP Server This skill provides an MCP server for interacting with [SilverBullet](https://silverbullet.md/), a self-hosted markdown-based note-taking app. ## Installation ### Via ClawdHub ```bash clawdhub install silverbullet ``` ### Manual ```bash cd ~/.clawdbot/skills/silverbullet uv venv source .venv/bin/activate uv pip install -e . ``` ## Configuration ### 1. Set SilverBullet URL ```bash export SILVERBULLET_URL="http://localhost:3000" ``` Or add to your shell profile (`~/.zshrc` / `~/.bashrc`). ### 2. Configure mcporter Add to `~/.mcporter/mcporter.json`: ```json { "servers": { "silverbullet": { "command": "python", "args": ["{baseDir}/server.py"], "transport": "stdio", "env": { "SILVERBULLET_URL": "http://localhost:3000" } } } } ``` Replace `{baseDir}` with the actual skill path (e.g., `~/.clawdbot/skills/silverbullet`). ### 3. Verify Installation ```bash mcporter list silverbullet ``` Should show all available tools. ## Available Tools | Tool | Description | |------|-------------| | `list_files` | List all files in the SilverBullet space | | `read_page` | Read markdown content from a page | | `write_page` | Create or update a page | | `delete_page` | Delete a page | | `append_to_page` | Append content to an existing page | | `search_pages` | Search pages by name pattern | | `get_page_metadata` | Get page metadata (modified, created, permissions) | | `ping_server` | Check if SilverBullet server is available | | `get_server_config` | Get server configuration | ## Usage Examples ### List all pages ```bash mcporter call silverbullet.list_files ``` ### Read a page ```bash mcporter call silverbullet.read_page path:"index.md" mcporter call silverbullet.read_page path:"journal/2024-01-15.md" ``` ### Create or update a page ```bash mcporter call silverbullet.write_page path:"notes/meeting.md" content:"# Meeting Notes\n\n- Item 1\n- Item 2" ``` ### Append to a page ```bash mcporter call silverbullet.append_to_page path:"journal/today.md" content:"## Evening Update\n\nFinished the project." ``` ### Search for pages ```bash mcporter call silverbullet.search_pages query:"meeting" ``` ### Delete a page ```bash mcporter call silverbullet.delete_page path:"drafts/old-note.md" ``` ### Check server status ```bash mcporter call silverbullet.ping_server ``` ## Natural Language Examples Once configured, you can ask Moltbot: - "List all my SilverBullet pages" - "Read my index page from SilverBullet" - "Create a new page called 'Project Ideas' with a list of features" - "Search for pages containing 'meeting' in the name" - "Append today's notes to my journal" - "What's the last modified date of my TODO page?" - "Is my SilverBullet server running?" ## Troubleshooting ### Server not responding 1. Check if SilverBullet is running: `curl http://localhost:3000/.ping` 2. Verify `SILVERBULLET_URL` is set correctly 3. Check firewall/network settings ### Permission denied errors SilverBullet pages can be read-only. Check the `X-Permission` header or use `get_page_metadata` to verify permissions. ### Tool not found 1. Verify mcporter config: `cat ~/.mcporter/mcporter.json` 2. Test server directly: `python {baseDir}/server.py` (should start without errors) 3. Check Python/uv installation: `which python3 uv` ## API Reference See [SilverBullet HTTP API](https://silverbullet.md/HTTP%20API) for full documentation of the underlying REST API.

path: README.md
content: # SilverBullet MCP Server

An MCP (Model Context Protocol) server for interacting with [SilverBullet](https://silverbullet.md/) note-taking app.

Works with Claude Desktop, Claude Code, Cursor, Moltbot, and any MCP-compatible client.

## Features

- **list_files** - List all files in your SilverBullet space
- **read_page** - Read markdown content from a page
- **write_page** - Create or update pages
- **delete_page** - Remove pages
- **append_to_page** - Append content to existing pages
- **search_pages** - Search pages by name
- **get_page_metadata** - Get page metadata without content
- **ping_server** - Check server availability
- **get_server_config** - Get server configuration

## Installation

### Using uv (Recommended)

```bash
# Clone or download this repository
cd silverbullet-skill

# Create virtual environment and install
uv venv
source .venv/bin/activate  # or .venv\Scripts\activate on Windows
uv pip install -e .
```

### Using pip

```bash
pip install -e .
```

## Configuration

Set your SilverBullet server URL:

```bash
export SILVERBULLET_URL="http://localhost:3000"
```

## Running the Server

### Stdio Transport (Default)

For use with Claude Desktop, Claude Code, etc:

```bash
python server.py
# or
uv run server.py
```

### HTTP/SSE Transport (For Moltbot/Daemon Mode)

```bash
python server.py --http --port 8080
```

## Integration

### Claude Desktop

Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "silverbullet": {
      "command": "uv",
      "args": [
        "--directory",
        "/ABSOLUTE/PATH/TO/silverbullet-skill",
        "run",
        "server.py"
      ],
      "env": {
        "SILVERBULLET_URL": "http://localhost:3000"
      }
    }
  }
}
```

### Claude Code

Add to your project's `.claude/settings.json` or global settings:

```json
{
  "mcpServers": {
    "silverbullet": {
      "command": "python",
      "args": ["/path/to/silverbullet-skill/server.py"],
      "env": {
        "SILVERBULLET_URL": "http://localhost:3000"
      }
    }
  }
}
```

### Moltbot (via mcporter)

#### Option 1: Stdio Transport

Add to `~/.mcporter/mcporter.json`:

```json
{
  "servers": {
    "silverbullet": {
      "command": "python",
      "args": ["/path/to/silverbullet-skill/server.py"],
      "transport": "stdio",
      "env": {
        "SILVERBULLET_URL": "http://localhost:3000"
      }
    }
  }
}
```

Then use via mcporter:

```bash
mcporter call silverbullet.list_files
mcporter call silverbullet.read_page path:"index.md"
mcporter call silverbullet.write_page path:"test.md" content:"# Hello World"
```

#### Option 2: HTTP Transport (Daemon Mode)

1. Start the server as a daemon:

```bash
python server.py --http --port 8080 &
```

2. Configure mcporter:

```json
{
  "servers": {
    "silverbullet": {
      "url": "http://localhost:8080/sse",
      "transport": "sse"
    }
  }
}
```

### Cursor

Add to Cursor's MCP settings:

```json
{
  "mcpServers": {
    "silverbullet": {
      "command": "python",
      "args": ["/path/to/silverbullet-skill/server.py"],
      "env": {
        "SILVERBULLET_URL": "http://localhost:3000"
      }
    }
  }
}
```

## Available Tools

| Tool | Description | Parameters |
|------|-------------|------------|
| `list_files` | List all files in space | `base_url?` |
| `read_page` | Read page content | `path`, `base_url?` |
| `write_page` | Create/update page | `path`, `content`, `base_url?` |
| `delete_page` | Delete a page | `path`, `base_url?` |
| `append_to_page` | Append to page | `path`, `content`, `base_url?` |
| `search_pages` | Search by name | `query`, `base_url?` |
| `get_page_metadata` | Get metadata only | `path`, `base_url?` |
| `ping_server` | Check availability | `base_url?` |
| `get_server_config` | Get server config | `base_url?` |

All tools accept an optional `base_url` parameter to override the default server URL.

## Moltbot Skill (Legacy)

The original curl-based skill is still available in `SKILL.md` for simpler setups.

## Development

```bash
# Install with dev dependencies
uv pip install -e ".[dev]"

# Run tests
pytest
```

## License

MIT


path: examples/demo.py
content: #!/usr/bin/env python3
"""
Demo script showing how to use the SilverBullet MCP server programmatically.

This demonstrates direct usage of the server functions without MCP transport.
"""

import asyncio
import os
import sys

# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from server import (
    list_files,
    read_page,
    write_page,
    search_pages,
    ping_server,
    get_server_config,
    append_to_page,
    delete_page,
)


async def main():
    """Run demo of SilverBullet MCP tools."""
    base_url = os.environ.get("SILVERBULLET_URL", "http://localhost:3000")
    print(f"Using SilverBullet at: {base_url}\n")

    # 1. Check server status
    print("=" * 50)
    print("1. Checking server status...")
    print("=" * 50)
    result = await ping_server(base_url)
    print(result)
    print()

    # 2. Get server config
    print("=" * 50)
    print("2. Getting server configuration...")
    print("=" * 50)
    result = await get_server_config(base_url)
    print(result)
    print()

    # 3. List all files
    print("=" * 50)
    print("3. Listing all files...")
    print("=" * 50)
    result = await list_files(base_url)
    print(result[:500] + "..." if len(result) > 500 else result)
    print()

    # 4. Read index page
    print("=" * 50)
    print("4. Reading index.md...")
    print("=" * 50)
    result = await read_page("index.md", base_url)
    print(result[:500] + "..." if len(result) > 500 else result)
    print()

    # 5. Search for pages
    print("=" * 50)
    print("5. Searching for pages with 'index'...")
    print("=" * 50)
    result = await search_pages("index", base_url)
    print(result)
    print()

    # 6. Create a test page
    print("=" * 50)
    print("6. Creating test page...")
    print("=" * 50)
    test_content = """# MCP Test Page

This page was created by the SilverBullet MCP server demo.

## Features Tested

- [x] Server connectivity
- [x] File listing
- [x] Page reading
- [x] Page creation

---
*Generated automatically*
"""
    result = await write_page("mcp-test.md", test_content, base_url)
    print(result)
    print()

    # 7. Read it back
    print("=" * 50)
    print("7. Reading test page back...")
    print("=" * 50)
    result = await read_page("mcp-test.md", base_url)
    print(result)
    print()

    # 8. Append to the test page
    print("=" * 50)
    print("8. Appending to test page...")
    print("=" * 50)
    result = await append_to_page("mcp-test.md", "## Appendix\n\nThis was appended!", base_url)
    print(result)
    print()

    # 9. Clean up - delete test page
    print("=" * 50)
    print("9. Cleaning up - deleting test page...")
    print("=" * 50)
    result = await delete_page("mcp-test.md", base_url)
    print(result)
    print()

    print("=" * 50)
    print("Demo complete!")
    print("=" * 50)


if __name__ == "__main__":
    asyncio.run(main())


path: pyproject.toml
content: [project]
name = "silverbullet-mcp"
version = "1.0.0"
description = "MCP server for SilverBullet note-taking app"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
    { name = "Your Name", email = "[REDACTED_SECRET]" }
]
keywords = ["mcp", "silverbullet", "notes", "markdown"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]

dependencies = [
    "mcp[cli]>=1.2.0",
    "httpx>=0.27.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0.0",
    "pytest-asyncio>=0.23.0",
]

[project.scripts]
silverbullet-mcp = "server:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["."]

[tool.pytest.ini_options]
asyncio_mode = "auto"


path: server.py
content: #!/usr/bin/env python3
"""
SilverBullet MCP Server

An MCP server that provides tools for interacting with SilverBullet
note-taking app via its REST API.
"""

import os
import logging
from typing import Any

import httpx
from mcp.server.fastmcp import FastMCP

# Configure logging to stderr (required for stdio transport)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("silverbullet-mcp")

# Initialize FastMCP server
mcp = FastMCP("silverbullet")

# Default base URL (can be overridden via environment variable)
DEFAULT_BASE_URL = os.environ.get("SILVERBULLET_URL", "http://localhost:3000")


def get_base_url(base_url: str | None = None) -> str:
    """Get the base URL, using default if not provided."""
    return base_url or DEFAULT_BASE_URL


async def make_request(
    method: str,
    url: str,
    content: str | None = None,
    headers: dict[str, str] | None = None,
) -> httpx.Response:
    """Make an HTTP request to SilverBullet."""
    async with httpx.AsyncClient(timeout=30.0) as client:
        request_headers = headers or {}
        if method == "PUT" and content is not None:
            request_headers["Content-Type"] = "text/markdown"

        response = await client.request(
            method=method,
            url=url,
            content=content,
            headers=request_headers,
        )
        return response


@mcp.tool()
async def list_files(base_url: str | None = None) -> str:
    """
    List all files in the SilverBullet space.

    Returns a JSON array of all files with metadata including
    name, lastModified, created, and permissions.

    Args:
        base_url: SilverBullet server URL (optional, uses SILVERBULLET_URL env var if not provided)
    """
    url = f"{get_base_url(base_url)}/.fs"

    try:
        response = await make_request("GET", url)
        response.raise_for_status()
        return response.text
    except httpx.HTTPStatusError as e:
        return f"Error: HTTP {e.response.status_code} - {e.response.text}"
    except Exception as e:
        return f"Error: {str(e)}"


@mcp.tool()
async def read_page(path: str, base_url: str | None = None) -> str:
    """
    Read the content of a page from SilverBullet.

    Args:
        path: Path to the page (e.g., "index.md" or "journal/2024-01-15.md")
        base_url: SilverBullet server URL (optional)

    Returns:
        The markdown content of the page
    """
    # Ensure path doesn't start with /
    path = path.lstrip("/")
    url = f"{get_base_url(base_url)}/.fs/{path}"

    try:
        response = await make_request("GET", url)
        response.raise_for_status()
        return response.text
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return f"Error: Page '{path}' not found"
        return f"Error: HTTP {e.response.status_code} - {e.response.text}"
    except Exception as e:
        return f"Error: {str(e)}"


@mcp.tool()
async def write_page(path: str, content: str, base_url: str | None = None) -> str:
    """
    Create or update a page in SilverBullet.

    Args:
        path: Path to the page (e.g., "notes/meeting.md")
        content: Markdown content to write
        base_url: SilverBullet server URL (optional)

    Returns:
        Success or error message
    """
    # Ensure path doesn't start with /
    path = path.lstrip("/")
    # Ensure .md extension
    if not path.endswith(".md"):
        path = f"{path}.md"

    url = f"{get_base_url(base_url)}/.fs/{path}"

    try:
        response = await make_request("PUT", url, content=content)
        response.raise_for_status()
        return f"Successfully wrote to '{path}'"
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 403:
            return f"Error: Permission denied - page '{path}' may be read-only"
        return f"Error: HTTP {e.response.status_code} - {e.response.text}"
    except Exception as e:
        return f"Error: {str(e)}"


@mcp.tool()
async def delete_page(path: str, base_url: str | None = None) -> str:
    """
    Delete a page from SilverBullet.

    Args:
        path: Path to the page to delete (e.g., "drafts/old-note.md")
        base_url: SilverBullet server URL (optional)

    Returns:
        Success or error message
    """
    # Ensure path doesn't start with /
    path = path.lstrip("/")
    url = f"{get_base_url(base_url)}/.fs/{path}"

    try:
        response = await make_request("DELETE", url)
        response.raise_for_status()
        return f"Successfully deleted '{path}'"
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return f"Error: Page '{path}' not found"
        if e.response.status_code == 403:
            return f"Error: Permission denied - cannot delete '{path}'"
        return f"Error: HTTP {e.response.status_code} - {e.response.text}"
    except Exception as e:
        return f"Error: {str(e)}"


@mcp.tool()
async def get_page_metadata(path: str, base_url: str | None = None) -> str:
    """
    Get metadata for a page without fetching its content.

    Args:
        path: Path to the page
        base_url: SilverBullet server URL (optional)

    Returns:
        JSON with lastModified, created, permission, and contentLength
    """
    import json

    # Ensure path doesn't start with /
    path = path.lstrip("/")
    url = f"{get_base_url(base_url)}/.fs/{path}"

    try:
        response = await make_request("GET", url, headers={"X-Get-Meta": "true"})
        response.raise_for_status()

        metadata = {
            "path": path,
            "lastModified": response.headers.get("X-Last-Modified"),
            "created": response.headers.get("X-Created"),
            "permission": response.headers.get("X-Permission", "ro"),
            "contentLength": response.headers.get("X-Content-Length"),
        }
        return json.dumps(metadata, indent=2)
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return f"Error: Page '{path}' not found"
        return f"Error: HTTP {e.response.status_code} - {e.response.text}"
    except Exception as e:
        return f"Error: {str(e)}"


@mcp.tool()
async def search_pages(query: str, base_url: str | None = None) -> str:
    """
    Search for pages by name pattern.

    Args:
        query: Search term to match against page names
        base_url: SilverBullet server URL (optional)

    Returns:
        JSON array of matching pages
    """
    import json

    url = f"{get_base_url(base_url)}/.fs"

    try:
        response = await make_request("GET", url)
        response.raise_for_status()

        files = response.json()
        query_lower = query.lower()

        # Filter files that match the query
        matches = [
            f for f in files
            if query_lower in f.get("name", "").lower()
        ]

        return json.dumps(matches, indent=2)
    except httpx.HTTPStatusError as e:
        return f"Error: HTTP {e.response.status_code} - {e.response.text}"
    except Exception as e:
        return f"Error: {str(e)}"


@mcp.tool()
async def append_to_page(path: str, content: str, base_url: str | None = None) -> str:
    """
    Append content to an existing page.

    Args:
        path: Path to the page
        content: Content to append
        base_url: SilverBullet server URL (optional)

    Returns:
        Success or error message
    """
    # Ensure path doesn't start with /
    path = path.lstrip("/")
    # Ensure .md extension
    if not path.endswith(".md"):
        path = f"{path}.md"

    url = f"{get_base_url(base_url)}/.fs/{path}"

    try:
        # First, read existing content
        response = await make_request("GET", url)

        if response.status_code == 404:
            # Page doesn't exist, create it
            existing_content = ""
        else:
            response.raise_for_status()
            existing_content = response.text

        # Append new content
        new_content = f"{existing_content}\n\n{content}" if existing_content else content

        # Write back
        write_response = await make_request("PUT", url, content=new_content)
        write_response.raise_for_status()

        return f"Successfully appended to '{path}'"
    except httpx.HTTPStatusError as e:
        return f"Error: HTTP {e.response.status_code} - {e.response.text}"
    except Exception as e:
        return f"Error: {str(e)}"


@mcp.tool()
async def ping_server(base_url: str | None = None) -> str:
    """
    Check if the SilverBullet server is available.

    Args:
        base_url: SilverBullet server URL (optional)

    Returns:
        Server status message
    """
    url = f"{get_base_url(base_url)}/.ping"

    try:
        response = await make_request("GET", url)
        if response.status_code == 200:
            return f"Server is available at {get_base_url(base_url)}"
        return f"Server responded with status {response.status_code}"
    except Exception as e:
        return f"Server unavailable: {str(e)}"


@mcp.tool()
async def get_server_config(base_url: str | None = None) -> str:
    """
    Get the SilverBullet server configuration.

    Args:
        base_url: SilverBullet server URL (optional)

    Returns:
        JSON with readOnly, spaceFolderPath, and indexPage
    """
    url = f"{get_base_url(base_url)}/.config"

    try:
        response = await make_request("GET", url)
        response.raise_for_status()
        return response.text
    except httpx.HTTPStatusError as e:
        return f"Error: HTTP {e.response.status_code} - {e.response.text}"
    except Exception as e:
        return f"Error: {str(e)}"


def main():
    """Run the MCP server."""
    import sys

    # Check for HTTP mode
    if "--http" in sys.argv:
        # HTTP transport for daemon mode
        port = 8080
        for i, arg in enumerate(sys.argv):
            if arg == "--port" and i + 1 < len(sys.argv):
                port = int(sys.argv[i + 1])

        logger.info(f"Starting HTTP server on port {port}")
        mcp.run(transport="sse", port=port)
    else:
        # Default: stdio transport
        logger.info("Starting stdio server")
        mcp.run(transport="stdio")


if __name__ == "__main__":
    main()


path: tests/__init__.py
content: # Tests for silverbullet-mcp


path: tests/test_server.py
content: """
Tests for SilverBullet MCP server.

Run with: pytest tests/
"""

import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx


# Import the server module
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from server import (
    get_base_url,
    list_files,
    read_page,
    write_page,
    delete_page,
    search_pages,
    ping_server,
)


class TestGetBaseUrl:
    """Tests for get_base_url helper."""

    def test_returns_provided_url(self):
        """Should return the provided URL when given."""
        result = get_base_url("http://example.com:3000")
        assert result == "http://example.com:3000"

    def test_returns_default_when_none(self):
        """Should return default URL when None provided."""
        with patch.dict(os.environ, {"SILVERBULLET_URL": "http://test:3000"}):
            # Re-import to pick up env var
            from server import DEFAULT_BASE_URL
            result = get_base_url(None)
            assert result is not None


class TestListFiles:
    """Tests for list_files tool."""

    @pytest.mark.asyncio
    async def test_list_files_success(self):
        """Should return file list on success."""
        mock_response = MagicMock()
        mock_response.text = '[{"name": "index.md"}]'
        mock_response.raise_for_status = MagicMock()

        with patch("server.make_request", new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            result = await list_files("http://localhost:3000")
            assert "index.md" in result

    @pytest.mark.asyncio
    async def test_list_files_error(self):
        """Should return error message on failure."""
        with patch("server.make_request", new_callable=AsyncMock) as mock_req:
            mock_req.side_effect = Exception("Connection failed")
            result = await list_files("http://localhost:3000")
            assert "Error" in result


class TestReadPage:
    """Tests for read_page tool."""

    @pytest.mark.asyncio
    async def test_read_page_success(self):
        """Should return page content on success."""
        mock_response = MagicMock()
        mock_response.text = "# Hello World"
        mock_response.raise_for_status = MagicMock()

        with patch("server.make_request", new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            result = await read_page("index.md", "http://localhost:3000")
            assert "Hello World" in result

    @pytest.mark.asyncio
    async def test_read_page_strips_leading_slash(self):
        """Should strip leading slash from path."""
        mock_response = MagicMock()
        mock_response.text = "content"
        mock_response.raise_for_status = MagicMock()

        with patch("server.make_request", new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            await read_page("/index.md", "http://localhost:3000")
            # Verify the URL doesn't have double slashes
            call_args = mock_req.call_args
            assert "/.fs//index.md" not in str(call_args)


class TestWritePage:
    """Tests for write_page tool."""

    @pytest.mark.asyncio
    async def test_write_page_adds_md_extension(self):
        """Should add .md extension if missing."""
        mock_response = MagicMock()
        mock_response.raise_for_status = MagicMock()

        with patch("server.make_request", new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            result = await write_page("test", "content", "http://localhost:3000")
            assert "test.md" in result


class TestSearchPages:
    """Tests for search_pages tool."""

    @pytest.mark.asyncio
    async def test_search_pages_filters_results(self):
        """Should filter files by query."""
        mock_response = MagicMock()
        mock_response.json.return_value = [
            {"name": "meeting-notes.md"},
            {"name": "index.md"},
            {"name": "meeting-2024.md"},
        ]
        mock_response.raise_for_status = MagicMock()

        with patch("server.make_request", new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            result = await search_pages("meeting", "http://localhost:3000")
            assert "meeting-notes.md" in result
            assert "meeting-2024.md" in result
            # index.md should not be in filtered results
            import json
            data = json.loads(result)
            names = [f["name"] for f in data]
            assert "index.md" not in names


class TestPingServer:
    """Tests for ping_server tool."""

    @pytest.mark.asyncio
    async def test_ping_success(self):
        """Should return available message on 200."""
        mock_response = MagicMock()
        mock_response.status_code = 200

        with patch("server.make_request", new_callable=AsyncMock) as mock_req:
            mock_req.return_value = mock_response
            result = await ping_server("http://localhost:3000")
            assert "available" in result

    @pytest.mark.asyncio
    async def test_ping_failure(self):
        """Should return unavailable message on error."""
        with patch("server.make_request", new_callable=AsyncMock) as mock_req:
            mock_req.side_effect = Exception("Connection refused")
            result = await ping_server("http://localhost:3000")
            assert "unavailable" in result


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)