diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70f1e2ef..862cf28c 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -206,7 +206,7 @@ jobs: uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox status - name: Run consolidated core suite - if: matrix.os_name == 'macOS' || matrix.python == '3.14.6' + if: matrix.os_name == 'macOS' || matrix.python == '3.14' run: | mkdir -p tests/out uv run --no-sync --no-sources pytest -q archivebox/tests --basetemp="tests/out/${{ matrix.os_name }}-python-${{ matrix.python }}" diff --git a/archivebox/mcp/README.md b/archivebox/mcp/README.md index 8b0aa42b..cf5a44e0 100644 --- a/archivebox/mcp/README.md +++ b/archivebox/mcp/README.md @@ -19,7 +19,9 @@ This is a lightweight, stateless MCP server that dynamically introspects Archive ### Start the MCP Server ```bash -archivebox mcp +request='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' +response="$(printf '%s\n' "$request" | "$UV_BINARY" run --project "$ARCHIVEBOX_PROJECT_DIR" --no-sync archivebox mcp)" +"$JQ_BINARY" -e '.id == 1 and .result.serverInfo.name == "archivebox-mcp"' <<< "$response" ``` The server runs in stdio mode, reading JSON-RPC 2.0 requests from stdin and writing responses to stdout. @@ -27,25 +29,30 @@ The server runs in stdio mode, reading JSON-RPC 2.0 requests from stdin and writ ### Example Client ```python -import subprocess import json +import os +import subprocess -# Start MCP server -proc = subprocess.Popen( - ['archivebox', 'mcp'], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - text=True -) - -# Send initialize request request = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}} -proc.stdin.write(json.dumps(request) + '\n') -proc.stdin.flush() - -# Read response -response = json.loads(proc.stdout.readline()) -print(response) +completed = subprocess.run( + [ + os.environ["UV_BINARY"], + "run", + "--project", + os.environ["ARCHIVEBOX_PROJECT_DIR"], + "--no-sync", + "archivebox", + "mcp", + ], + input=json.dumps(request) + "\n", + capture_output=True, + text=True, + check=True, + timeout=30, +) +response = json.loads(completed.stdout) +assert response["id"] == 1 +assert response["result"]["serverInfo"]["name"] == "archivebox-mcp" ``` ### Example Requests @@ -98,16 +105,21 @@ The server exposes all ArchiveBox CLI commands: Instead of manually defining schemas, the server uses Click's introspection API to automatically generate MCP tool definitions: ```python -# Auto-discover commands -from archivebox.cli import ArchiveBoxGroup -cli_group = ArchiveBoxGroup() -all_commands = cli_group.all_subcommands +import click -# Auto-generate schemas from Click metadata -for cmd_name in all_commands: - click_cmd = cli_group.get_command(None, cmd_name) - # Extract params, types, help text, etc. - tool_schema = click_command_to_mcp_tool(cmd_name, click_cmd) +from archivebox.cli import ArchiveBoxGroup +from archivebox.mcp.server import click_command_to_mcp_tool + +cli_group = ArchiveBoxGroup() +context = click.Context(cli_group) + +tools = [] +for command_name in cli_group.all_subcommands: + command = cli_group.get_command(context, command_name) + assert command is not None + tools.append(click_command_to_mcp_tool(command_name, command)) + +assert {tool["name"] for tool in tools} == set(cli_group.all_subcommands) ``` ### Tool Execution @@ -115,10 +127,14 @@ for cmd_name in all_commands: Commands are executed using Click's `CliRunner`: ```python -from click.testing import CliRunner +from archivebox.mcp.server import MCPServer -runner = CliRunner() -result = runner.invoke(click_command, args) +response = MCPServer().handle_request( + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, +) +assert response["id"] == 2 +assert response["result"]["tools"] +assert all("name" in tool and "inputSchema" in tool for tool in response["result"]["tools"]) ``` ## Files diff --git a/docs/codeblocks.toml b/docs/codeblocks.toml index 61ef0954..bb9e82bd 100644 --- a/docs/codeblocks.toml +++ b/docs/codeblocks.toml @@ -4,7 +4,7 @@ environments = ["core", "merge", "publishing", "docker", "root", "macos", "freeb [syntax] executed = ["bash", "sh", "python"] shell_syntax_only = ["console"] -structured = ["yaml", "ini", "sql", "nginx", "mermaid"] +structured = ["json", "yaml", "ini", "sql", "nginx", "mermaid"] prose = ["text"] directive_prefixes = ["{"] console_blocks = [ @@ -18,9 +18,6 @@ console_blocks = [ "README.md::line 1524", "README.md::line 1537", "README.md::line 1601", - "README.md::line 1617", - "AGENTS.md::line 84", - "skills/archivebox/SKILL.md::line 78", ] [ci.standard] @@ -34,6 +31,7 @@ macos = "macos-15" [ci.core_shards] metadata = [ "README.md", + "archivebox/mcp/README.md", "docs/Changelog.md", "docs/Chromium-Install.md", ] @@ -64,6 +62,7 @@ openbsd = "vmactions/openbsd-vm@v1.4.5" [files] "README.md" = "core" "AGENTS.md" = "core" +"archivebox/mcp/README.md" = "core" "skills/archivebox/SKILL.md" = "core" "docs/Changelog.md" = "core" "docs/Chromium-Install.md" = "core" diff --git a/docs/test_codeblocks_manifest.py b/docs/test_codeblocks_manifest.py index 1f86a2cd..f13ccfa8 100644 --- a/docs/test_codeblocks_manifest.py +++ b/docs/test_codeblocks_manifest.py @@ -1,5 +1,6 @@ import configparser from collections import Counter +import json from pathlib import Path import sqlite3 import subprocess @@ -15,7 +16,11 @@ WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "docs.yml" def markdown_paths() -> tuple[Path, ...]: - candidates = [REPO_ROOT / "README.md", REPO_ROOT / "AGENTS.md"] + candidates = [ + REPO_ROOT / "README.md", + REPO_ROOT / "AGENTS.md", + REPO_ROOT / "archivebox" / "mcp" / "README.md", + ] candidates.extend(sorted((REPO_ROOT / "skills").rglob("*.md"))) candidates.extend(sorted((REPO_ROOT / "docs").rglob("*.md"))) @@ -106,7 +111,9 @@ def test_structured_data_fences_parse() -> None: ) for nodeid, block in blocks.items(): - if block.syntax == "yaml": + if block.syntax == "json": + json.loads(block.code) + elif block.syntax == "yaml": list(yaml.safe_load_all(block.code)) elif block.syntax == "ini": parser = configparser.ConfigParser()