commit 6a1b671d7996f1f2770549d675ca88185576dd82 Author: caoimhinr Date: Tue Jun 2 23:07:29 2026 +0200 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..eaed9f2 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# pastebin-mcp + +MCP server for the homelab pastebin service. Exposes create, read, list, and delete +operations as MCP tools so agents can interact with pastebin programmatically. + +## Tools + +| Tool | Auth required | Description | +|------|---------------|-------------| +| `create_paste` | No | Create a paste; returns URL and ID | +| `get_paste` | No | Fetch full paste metadata + content | +| `get_raw` | No | Fetch raw paste text only | +| `list_pastes` | Yes (API key) | List recent pastes with pagination | +| `delete_paste` | Yes (API key) | Permanently delete a paste | + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PASTEBIN_URL` | `http://192.168.0.242:3002` | Pastebin service base URL | +| `PASTEBIN_API_KEY` | *(empty)* | API key for privileged operations | +| `PASTEBIN_MCP_TRANSPORT` | `stdio` | Transport: `stdio`, `sse`, or `streamable-http` | +| `FASTMCP_HOST` | `127.0.0.1` | Bind host for HTTP transports | +| `FASTMCP_PORT` | `8000` | Bind port for HTTP transports | +| `FASTMCP_STREAMABLE_HTTP_PATH` | `/mcp` | HTTP path for streamable-http transport | + +## Install + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +## Run (HTTP / MCP endpoint) + +```bash +PASTEBIN_MCP_TRANSPORT=streamable-http \ +FASTMCP_HOST=0.0.0.0 \ +FASTMCP_PORT=8004 \ +PASTEBIN_API_KEY= \ +python mcp_server.py +``` + +## Run (stdio for Claude Desktop / claude mcp add) + +```bash +PASTEBIN_API_KEY= pastebin-mcp +``` + +## Register globally (HTTP) + +```bash +claude mcp add --transport http --scope user pastebin-mcp http://192.168.0.242:8004/mcp +``` diff --git a/mcp_server.py b/mcp_server.py new file mode 100644 index 0000000..e5f3946 --- /dev/null +++ b/mcp_server.py @@ -0,0 +1,234 @@ +""" +pastebin-mcp — MCP server for the homelab pastebin service. + +Wraps the pastebin REST API so agents can create, retrieve, list, and +delete pastes programmatically. + +Configure via environment variables: + PASTEBIN_URL — Pastebin base URL (default http://192.168.0.242:3002) + PASTEBIN_API_KEY — API key for privileged operations (list, delete) + +Transport: + PASTEBIN_MCP_TRANSPORT — stdio (default), sse, or streamable-http + FASTMCP_HOST — bind host for HTTP transports (default 127.0.0.1) + FASTMCP_PORT — bind port for HTTP transports (default 8000) + FASTMCP_STREAMABLE_HTTP_PATH — path for streamable-http (default /mcp) +""" + +import os +from typing import Optional + +import httpx +from mcp.server.fastmcp import FastMCP + +PASTEBIN_URL = os.environ.get("PASTEBIN_URL", "http://192.168.0.242:3002").rstrip("/") +PASTEBIN_API_KEY = os.environ.get("PASTEBIN_API_KEY", "") + +MCP_TRANSPORT = os.getenv("PASTEBIN_MCP_TRANSPORT", "stdio").strip().lower() or "stdio" +MCP_HOST = os.getenv("FASTMCP_HOST", "127.0.0.1").strip() or "127.0.0.1" +MCP_PORT = int(os.getenv("FASTMCP_PORT", "8000")) +MCP_PATH = os.getenv("FASTMCP_STREAMABLE_HTTP_PATH", "/mcp").strip() or "/mcp" + +mcp = FastMCP( + "pastebin-mcp", + host=MCP_HOST, + port=MCP_PORT, + streamable_http_path=MCP_PATH, + json_response=True, + stateless_http=True, +) + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +def _client() -> httpx.Client: + """Return an httpx client pointed at the pastebin base URL.""" + return httpx.Client(base_url=PASTEBIN_URL, timeout=10.0) + + +def _authed_client() -> httpx.Client: + """Return an httpx client with the API key header set for privileged ops.""" + return httpx.Client( + base_url=PASTEBIN_URL, + headers={"X-API-Key": PASTEBIN_API_KEY}, + timeout=10.0, + ) + + +def _check(r: httpx.Response) -> dict | list | str: + """Raise on non-2xx responses; return parsed JSON or raw text.""" + if not r.is_success: + raise RuntimeError(f"Pastebin API error {r.status_code}: {r.text}") + content_type = r.headers.get("content-type", "") + if "application/json" in content_type: + return r.json() + return r.text + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + +@mcp.tool() +def create_paste( + content: str, + language: str = "plaintext", + title: str = "", + expires_in_hours: Optional[int] = None, + private: bool = False, +) -> dict: + """ + Create a new paste and return its URL and ID. + + No authentication is required to create a paste. The paste is immediately + accessible at the returned URL. + + Args: + content: The text content of the paste. May be any UTF-8 string. + language: Syntax-highlighting language hint, e.g. ``"python"``, + ``"javascript"``, ``"yaml"``, ``"plaintext"`` (default). + The pastebin service uses this for display only. + title: Optional human-readable title for the paste. Empty string + means no title (default). + expires_in_hours: Optional number of hours until the paste expires and is + automatically deleted. ``None`` (default) means the paste + never expires. + private: If ``True``, the paste will not appear in public listings. + Defaults to ``False``. + + Returns: + Dict with keys: + - ``id`` — unique paste identifier (used in other tool calls) + - ``url`` — full public URL to view the paste + - ``raw_url`` — direct URL to the raw paste content + """ + payload: dict = {"content": content, "language": language, "private": private} + if title: + payload["title"] = title + if expires_in_hours is not None: + payload["expires_in_hours"] = expires_in_hours + + with _client() as c: + result: dict = _check(c.post("/api/paste", json=payload)) # type: ignore[assignment] + + paste_id = result.get("id", "") + return { + "id": paste_id, + "url": f"{PASTEBIN_URL}/paste/{paste_id}" if paste_id else "", + "raw_url": f"{PASTEBIN_URL}/raw/{paste_id}" if paste_id else "", + **result, + } + + +@mcp.tool() +def get_paste(paste_id: str) -> dict: + """ + Retrieve the full metadata and content for a single paste. + + No authentication is required to read a paste. + + Args: + paste_id: The unique identifier of the paste, as returned by + ``create_paste`` or ``list_pastes``. + + Returns: + Full paste object with keys including: + - ``id`` — paste identifier + - ``content`` — full text content + - ``language`` — syntax language hint + - ``title`` — paste title (may be empty) + - ``private`` — whether the paste is private + - ``created_at`` — ISO-8601 creation timestamp + - ``expires_at`` — ISO-8601 expiry timestamp, or ``null`` if never + """ + with _client() as c: + return _check(c.get(f"/api/paste/{paste_id}")) # type: ignore[return-value] + + +@mcp.tool() +def get_raw(paste_id: str) -> str: + """ + Retrieve the raw text content of a paste as a plain string. + + Useful when you only need the content itself without metadata overhead. + No authentication is required. + + Args: + paste_id: The unique identifier of the paste, as returned by + ``create_paste`` or ``list_pastes``. + + Returns: + The raw text content of the paste as a plain string. + """ + with _client() as c: + return _check(c.get(f"/raw/{paste_id}")) # type: ignore[return-value] + + +@mcp.tool() +def list_pastes(limit: int = 50, offset: int = 0) -> list: + """ + List recent pastes from the pastebin service. + + Requires the ``PASTEBIN_API_KEY`` environment variable to be set. + Returns pastes in reverse-chronological order (newest first). + + Args: + limit: Maximum number of pastes to return (default 50). The server + may cap this at a lower value. + offset: Zero-based offset for pagination (default 0). Use in + combination with ``limit`` to page through results. + + Returns: + List of paste summary objects, each containing: + - ``id`` — paste identifier + - ``title`` — paste title (may be empty) + - ``language`` — syntax language hint + - ``private`` — whether the paste is private + - ``created_at`` — ISO-8601 creation timestamp + - ``expires_at`` — ISO-8601 expiry timestamp, or ``null`` + Note: the ``content`` field is typically omitted from listing responses + to reduce payload size. Use ``get_paste`` to fetch full content. + """ + with _authed_client() as c: + return _check(c.get("/api/pastes", params={"limit": limit, "offset": offset})) # type: ignore[return-value] + + +@mcp.tool() +def delete_paste(paste_id: str) -> dict: + """ + Permanently delete a paste. + + Requires the ``PASTEBIN_API_KEY`` environment variable to be set. + This operation is irreversible. + + Args: + paste_id: The unique identifier of the paste to delete, as returned + by ``create_paste`` or ``list_pastes``. + + Returns: + Dict with: + - ``deleted`` — ``True`` if the paste was successfully deleted + - ``paste_id`` — the ID of the deleted paste + Raises ``RuntimeError`` if the paste does not exist or the API key + is missing or invalid. + """ + with _authed_client() as c: + r = c.delete(f"/api/paste/{paste_id}") + if not r.is_success: + raise RuntimeError(f"Pastebin API error {r.status_code}: {r.text}") + return {"deleted": True, "paste_id": paste_id} + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + if MCP_TRANSPORT not in {"stdio", "sse", "streamable-http"}: + raise ValueError("PASTEBIN_MCP_TRANSPORT must be stdio, sse, or streamable-http.") + mcp.run(transport=MCP_TRANSPORT) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5b1c883 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "pastebin-mcp" +version = "0.1.0" +description = "MCP server for the homelab pastebin service" +requires-python = ">=3.11" +dependencies = [ + "mcp[cli]>=1.0", + "httpx>=0.28", +] + +[project.scripts] +pastebin-mcp = "mcp_server:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["pastebin_mcp*"]