diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 533de1e..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Deploy MCP Server API - -on: - push: - branches: [ main ] - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout source - uses: actions/checkout@v4 - - - name: Install AWS Copilot CLI - run: | - curl -Lo copilot-cli https://github.com/aws/copilot-cli/releases/latest/download/copilot-linux - chmod +x copilot-cli - sudo mv copilot-cli /usr/local/bin/copilot - - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-1 - - - name: Deploy service - run: copilot svc deploy --name mcp-server-api diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml deleted file mode 100644 index a8a21b8..0000000 --- a/.github/workflows/publish-mcp-registry.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Publish MCP Registry - -on: - push: - branches: [ main ] - paths: - - server.json - workflow_dispatch: - -concurrency: - group: publish-mcp-registry - cancel-in-progress: false - -jobs: - publish: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - - steps: - - name: Check out repository - uses: actions/checkout@v5 - - - name: Check registry version alignment - run: | - PROJECT_VERSION="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" - REGISTRY_VERSION="$(jq -r '.version' server.json)" - if [[ "${PROJECT_VERSION}" != "${REGISTRY_VERSION}" ]]; then - echo "::error title=Version mismatch::pyproject.toml is ${PROJECT_VERSION}, but server.json is ${REGISTRY_VERSION}. Keep both versions aligned." - exit 1 - fi - - # Registry versions are immutable. Every server.json metadata update must - # use a version that has not previously been published. - - name: Check registry version is unpublished - run: | - SERVER_NAME="$(jq -r '.name' server.json)" - SERVER_VERSION="$(jq -r '.version' server.json)" - ENCODED_NAME="$(jq -rn --arg value "${SERVER_NAME}" '$value | @uri')" - ENCODED_VERSION="$(jq -rn --arg value "${SERVER_VERSION}" '$value | @uri')" - REGISTRY_URL="https://registry.modelcontextprotocol.io/v0.1/servers/${ENCODED_NAME}/versions/${ENCODED_VERSION}" - HTTP_STATUS="$(curl --silent --show-error --location \ - --output registry-version.json \ - --write-out '%{http_code}' \ - "${REGISTRY_URL}")" - - case "${HTTP_STATUS}" in - 404) - ;; - 200) - echo "::error title=Registry version already exists::${SERVER_NAME} ${SERVER_VERSION} is already published. Bump version in server.json and pyproject.toml before publishing metadata changes." - exit 1 - ;; - *) - echo "::error title=Registry lookup failed::The registry returned HTTP ${HTTP_STATUS} while checking ${SERVER_NAME} ${SERVER_VERSION}." - cat registry-version.json - exit 1 - ;; - esac - - - name: Install MCP publisher - env: - MCP_PUBLISHER_VERSION: v1.8.1 - MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc - run: | - curl --fail --location --silent --show-error \ - --output mcp-publisher.tar.gz \ - "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" - echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum --check - - tar -xzf mcp-publisher.tar.gz mcp-publisher - chmod +x mcp-publisher - - - name: Authenticate to MCP Registry - run: ./mcp-publisher login github-oidc - - - name: Publish server metadata - run: ./mcp-publisher publish server.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..66ab8d7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,247 @@ +name: Release + +# Everything that ships a version runs from here, and only when a release tag +# is pushed: +# +# git tag v1.0.2 && git push origin v1.0.2 +# +# Publishing a GitHub release with a new tag pushes the tag too, so that works +# as well. The tag must match the version in pyproject.toml, server.json and +# mcpb/manifest.json. +# +# Job graph: +# +# verify (tag matches the versions) -+ +# +--> bundle, deploy, registry +# test (full pytest suite) -+ +# +# Nothing ships unless both gates pass. The three shipping jobs are independent +# of each other, so a failed one can be re-run on its own ("Re-run failed jobs") +# without repeating the rest: +# bundle builds the MCP Bundle and attaches it to the GitHub release, +# creating the release if it does not exist yet +# deploy deploys the hosted server (mcp.serpapi.com) with AWS Copilot +# registry publishes server.json to the MCP Registry +# +# Manual runs (workflow_dispatch) must select a release tag as the ref, and +# the checkboxes choose which of the three shipping jobs to execute. Untick +# `registry` when re-running a version that is already published: registry +# versions are immutable, so that job refuses to run twice. + +on: + push: + tags: [ "v*" ] + workflow_dispatch: + inputs: + deploy: + description: Deploy the hosted server with AWS Copilot + type: boolean + default: true + bundle: + description: Build the MCP Bundle and attach it to the GitHub release + type: boolean + default: true + registry: + description: Publish server.json to the MCP Registry + type: boolean + default: true + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + name: Verify tag and versions + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Check the tag matches the project version everywhere + run: | + if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then + echo "::error title=Not a tag::Run this workflow on a release tag (got ${GITHUB_REF_TYPE} ${GITHUB_REF_NAME})." + exit 1 + fi + PROJECT_VERSION="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + REGISTRY_VERSION="$(jq -r '.version' server.json)" + MANIFEST_VERSION="$(jq -r '.version' mcpb/manifest.json)" + if [[ "${GITHUB_REF_NAME}" != "v${PROJECT_VERSION}" ]]; then + echo "::error title=Tag/version mismatch::Tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${PROJECT_VERSION} (expected v${PROJECT_VERSION})." + exit 1 + fi + if [[ "${PROJECT_VERSION}" != "${REGISTRY_VERSION}" ]]; then + echo "::error title=Version mismatch::pyproject.toml is ${PROJECT_VERSION}, but server.json is ${REGISTRY_VERSION}. Keep all three versions aligned." + exit 1 + fi + if [[ "${PROJECT_VERSION}" != "${MANIFEST_VERSION}" ]]; then + echo "::error title=Version mismatch::pyproject.toml is ${PROJECT_VERSION}, but mcpb/manifest.json is ${MANIFEST_VERSION}. Keep all three versions aligned." + exit 1 + fi + echo "Releasing ${PROJECT_VERSION} from tag ${GITHUB_REF_NAME}." + + test: + name: Tests + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.11.8" + python-version: "3.13" + enable-cache: true + + - name: Install dependencies + run: uv sync --group dev --frozen + + - name: Run tests + run: uv run pytest -q + + bundle: + name: MCP Bundle + needs: [verify, test] + if: github.event_name == 'push' || inputs.bundle + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.11.8" + python-version: "3.13" + enable-cache: true + + - name: Install Node.js (MCPB CLI) + uses: actions/setup-node@v4 + with: + node-version: "22" + + # Regenerates the engine schemas from the SerpApi Playground so the + # released bundle carries the current engine list, packs the bundle and + # smoke-tests it over stdio. + - name: Build and smoke-test the bundle + run: uv run --no-project mcpb/build.py + + - name: Create the GitHub release if it does not exist yet + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + echo "Release ${GITHUB_REF_NAME} already exists; attaching the bundle to it." + else + gh release create "${GITHUB_REF_NAME}" --verify-tag --title "${GITHUB_REF_NAME}" --generate-notes + fi + + - name: Attach the bundle to the release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${GITHUB_REF_NAME}" dist/*.mcpb --clobber + + - name: Upload bundle as a workflow artifact + uses: actions/upload-artifact@v4 + with: + name: serpapi-mcp-mcpb + path: dist/*.mcpb + if-no-files-found: error + + deploy: + name: Deploy hosted server + needs: [verify, test] + if: github.event_name == 'push' || inputs.deploy + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install AWS Copilot CLI + run: | + curl -Lo copilot-cli https://github.com/aws/copilot-cli/releases/latest/download/copilot-linux + chmod +x copilot-cli + sudo mv copilot-cli /usr/local/bin/copilot + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Deploy service + run: copilot svc deploy --name mcp-server-api + + registry: + name: Publish to MCP Registry + needs: [verify, test] + if: github.event_name == 'push' || inputs.registry + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + # Registry versions are immutable. Every server.json metadata update must + # use a version that has not previously been published. + - name: Check registry version is unpublished + run: | + SERVER_NAME="$(jq -r '.name' server.json)" + SERVER_VERSION="$(jq -r '.version' server.json)" + ENCODED_NAME="$(jq -rn --arg value "${SERVER_NAME}" '$value | @uri')" + ENCODED_VERSION="$(jq -rn --arg value "${SERVER_VERSION}" '$value | @uri')" + REGISTRY_URL="https://registry.modelcontextprotocol.io/v0.1/servers/${ENCODED_NAME}/versions/${ENCODED_VERSION}" + HTTP_STATUS="$(curl --silent --show-error --location \ + --output registry-version.json \ + --write-out '%{http_code}' \ + "${REGISTRY_URL}")" + + case "${HTTP_STATUS}" in + 404) + ;; + 200) + echo "::error title=Registry version already exists::${SERVER_NAME} ${SERVER_VERSION} is already published. Bump the version in pyproject.toml, server.json and mcpb/manifest.json and tag a new release." + exit 1 + ;; + *) + echo "::error title=Registry lookup failed::The registry returned HTTP ${HTTP_STATUS} while checking ${SERVER_NAME} ${SERVER_VERSION}." + cat registry-version.json + exit 1 + ;; + esac + + - name: Install MCP publisher + env: + MCP_PUBLISHER_VERSION: v1.8.1 + MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc + run: | + curl --fail --location --silent --show-error \ + --output mcp-publisher.tar.gz \ + "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" + echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum --check - + tar -xzf mcp-publisher.tar.gz mcp-publisher + chmod +x mcp-publisher + + - name: Authenticate to MCP Registry + run: ./mcp-publisher login github-oidc + + - name: Publish server metadata + run: ./mcp-publisher publish server.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9dd8f1e..8bf09cf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,14 +30,19 @@ jobs: - name: Install dependencies run: uv sync --group dev --frozen - - name: Check registry version alignment + - name: Check registry and bundle version alignment run: | PROJECT_VERSION="$(uv run --no-sync python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" REGISTRY_VERSION="$(jq -r '.version' server.json)" + MANIFEST_VERSION="$(jq -r '.version' mcpb/manifest.json)" if [[ "${PROJECT_VERSION}" != "${REGISTRY_VERSION}" ]]; then echo "::error title=Version mismatch::pyproject.toml is ${PROJECT_VERSION}, but server.json is ${REGISTRY_VERSION}. Keep both versions aligned." exit 1 fi + if [[ "${PROJECT_VERSION}" != "${MANIFEST_VERSION}" ]]; then + echo "::error title=Version mismatch::pyproject.toml is ${PROJECT_VERSION}, but mcpb/manifest.json is ${MANIFEST_VERSION}. Keep both versions aligned." + exit 1 + fi - name: Run tests run: uv run pytest -q diff --git a/.gitignore b/.gitignore index 43ddcf9..f631b6b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store scratch/ +*.mcpb # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/.mcpbignore b/.mcpbignore new file mode 100644 index 0000000..24b43f8 --- /dev/null +++ b/.mcpbignore @@ -0,0 +1,41 @@ +# Trims the git-tracked files that mcpb/build.py stages into the MCP Bundle. +# The MCPB CLI reads .mcpbignore from the root of the directory it packs, so +# this file lives at the project root (like .gitignore) and patterns are +# relative to it; build.py stages it next to mcpb/manifest.json before packing. +# +# At runtime the server needs manifest.json, pyproject.toml, uv.lock, +# .python-version, src/ and engines/ (README.md and LICENSE ride along). +# engines/ is regenerated by build-engines.py at build time, and Claude +# Desktop installs the dependencies from uv.lock with uv, so nothing else +# has to ship. + +# Environments, caches and build output +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ +build/ +dist/ +*.mcpb +server/lib/ +server/venv/ + +# Local configuration and editor state +.env +.env.example +.idea/ +.vscode/ + +# Hosted deployment, CI, tests and packaging sources +.github/ +copilot/ +tests/ +Dockerfile +smithery.yaml +server.json +build-engines.py +mcpb/ +.mcpbignore diff --git a/README.md b/README.md index 1557e9d..a538c47 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A Model Context Protocol (MCP) server implementation that integrates with [SerpA - **JSON Responses (default)**: Structured JSON output with complete or compact modes - **Markdown Responses**: Cut token usage by 50% on average and by more than 90% for APIs with complex nested JSON. - **Interactive UI (MCP Apps)**: Opt-in `search_table` and `search_dashboard` tools that render results as an interactive UI in supporting hosts +- **Claude Desktop Extension**: One-click local install from an [MCP Bundle](https://github.com/modelcontextprotocol/mcpb) (`.mcpb`), see below ## Quick Start @@ -79,6 +80,30 @@ Configure Claude Desktop: Get your API key: [serpapi.com/manage-api-key](https://serpapi.com/manage-api-key) +### Claude Desktop Extension (MCP Bundle) + +For a local, one-click install, download the `.mcpb` bundle from the [latest release](https://github.com/serpapi/serpapi-mcp/releases/latest) (or build it as below) and open it with Claude Desktop (or drop it onto **Settings → Extensions**). Claude Desktop asks for your SerpApi API key during install, stores it as a sensitive setting, and runs the server locally over stdio. The bundle uses the MCPB `uv` runtime: it ships only the source, `pyproject.toml` and `uv.lock`, and Claude Desktop provisions Python and the locked dependencies with uv at install time, so nothing is vendored and one bundle works on macOS, Windows and Linux. + +```bash +uv run mcpb/build.py # needs Node.js for the MCPB CLI; writes dist/serpapi-mcp-.mcpb +``` + +Everything bundle-related lives in [mcpb/](mcpb/), plus [.mcpbignore](.mcpbignore) at the project root. The build regenerates the engine schemas from the SerpApi Playground (`--no-rebuild-engines` bundles `engines/` from the working tree instead), validates [mcpb/manifest.json](mcpb/manifest.json), packs the git-tracked files minus [.mcpbignore](.mcpbignore) with the manifest at the bundle root, then installs it into a temp dir and starts it over stdio to make sure it works (`--no-smoke` skips that last step). The bundle is only built at release time: pushing a `v` tag runs the release workflow, which runs the test suite and then deploys the hosted server, publishes the MCP Registry entry, and builds the bundle and attaches it to the GitHub release. Pull requests run the manifest and stdio entry point tests in `tests/test_mcpb.py` but do not pack a bundle. + +The same stdio entry point works with any local MCP host that launches servers as a subprocess: + +```json +{ + "mcpServers": { + "serpapi": { + "command": "uv", + "args": ["run", "--directory", "/path/to/serpapi-mcp", "--frozen", "--no-dev", "src/stdio.py"], + "env": { "SERPAPI_API_KEY": "YOUR_SERPAPI_API_KEY" } + } + } +} +``` + ## Authentication Two methods are supported: @@ -152,6 +177,15 @@ uv sync && uv run src/server.py # Docker docker build -t serpapi-mcp . && docker run -p 8000:8000 serpapi-mcp +# Build the Claude Desktop extension (MCP Bundle); rebuilds engines, needs Node.js for the MCPB CLI +uv run mcpb/build.py + +# Release: bump the version in pyproject.toml, server.json and mcpb/manifest.json, then tag it. +# Nothing ships on a plain push to main. The tag runs the release workflow, which runs the test +# suite and then deploys the hosted server, publishes server.json to the MCP Registry, and builds +# the MCP Bundle and attaches it to the GitHub release. +git tag v1.0.2 && git push origin v1.0.2 + # Regenerate engine resources (Playground scrape) python build-engines.py diff --git a/mcpb/README.md b/mcpb/README.md new file mode 100644 index 0000000..d5181ea --- /dev/null +++ b/mcpb/README.md @@ -0,0 +1,43 @@ +# MCP Bundle (Claude Desktop extension) + +Everything needed to package this server as an [MCP Bundle](https://github.com/modelcontextprotocol/mcpb) (`.mcpb`) for Claude Desktop lives in this folder, so the project root stays a plain Python project. The one exception is [`.mcpbignore`](../.mcpbignore), which sits at the project root where the MCPB CLI looks for it (like `.gitignore`). + +| File | Purpose | +| --- | --- | +| `manifest.json` | The MCPB manifest. It uses the `uv` runtime: Claude Desktop runs `uv run --directory --frozen --no-dev src/stdio.py` and installs the locked dependencies itself, so nothing is vendored. | +| `build.py` | Builds `dist/serpapi-mcp-.mcpb`: rebuilds the engine schemas, lays out the bundle, packs it with the MCPB CLI, verifies the archive and smoke-tests it over stdio. | +| [`../.mcpbignore`](../.mcpbignore) | Trims the git-tracked files down to what the server needs at runtime; tests, CI, deployment files and this folder stay out. | + +The stdio entry point is [`src/stdio.py`](../src/stdio.py); it belongs to the server (any local MCP host can launch it), not to the packaging. The manifest tests live in [`tests/test_mcpb.py`](../tests/test_mcpb.py). + +## Build + +Run from the project root. Node.js is needed for the MCPB CLI (via `npx`), and the default build needs network access to regenerate the engine schemas. + +```bash +uv run mcpb/build.py # dist/serpapi-mcp-.mcpb +uv run mcpb/build.py --no-rebuild-engines # bundle engines/ from the working tree +uv run mcpb/build.py --no-smoke # skip the install-and-start check +``` + +## Bundle layout + +`manifest.json` has to sit at the root of the packed directory, so the build copies it there next to the git-tracked files (`.mcpbignore` is already at the project root, so it is staged like any other tracked file): + +``` +serpapi-mcp-.mcpb +├── manifest.json <- mcpb/manifest.json +├── pyproject.toml, uv.lock, .python-version, LICENSE, README.md +├── src/ <- git-tracked files minus .mcpbignore (git add anything new that must ship) +└── engines/ <- regenerated from the SerpApi Playground at build time +``` + +## Releasing + +Bump the version in `pyproject.toml`, `server.json` and `mcpb/manifest.json` (CI checks that they match), then push a matching tag: + +```bash +git tag v1.0.2 && git push origin v1.0.2 +``` + +The [release workflow](../.github/workflows/release.yml) runs the test suite, then builds the bundle, creates the GitHub release if it does not exist yet and attaches the `.mcpb` to it; the same workflow also deploys the hosted server and publishes `server.json` to the MCP Registry, so a tag is the only thing that ships or builds anything. Pull requests only run the manifest and stdio entry point tests in [`tests/test_mcpb.py`](../tests/test_mcpb.py); build locally with `uv run mcpb/build.py` to check a bundle before tagging. diff --git a/mcpb/build.py b/mcpb/build.py new file mode 100644 index 0000000..e38d2c1 --- /dev/null +++ b/mcpb/build.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Build the MCP Bundle (.mcpb) that installs this server into Claude Desktop. + +The bundle uses the MCPB ``uv`` runtime (https://github.com/modelcontextprotocol/mcpb): +it ships the source tree plus ``pyproject.toml`` and ``uv.lock``, and the host +installs the locked dependencies with uv when the bundle is installed. Nothing +is vendored, so one bundle works on every platform and architecture. + +Packing uses the official MCPB CLI through ``npx`` (Node.js is needed to build +the bundle, not to run it). + +Usage (from the project root): + uv run mcpb/build.py # writes dist/serpapi-mcp-.mcpb + uv run mcpb/build.py --no-rebuild-engines # bundle engines/ from the working tree + uv run mcpb/build.py --no-smoke # skip the install-and-start check + +What goes in (manifest.json has to sit at the bundle root, so it is copied +out of this folder): + * mcpb/manifest.json, copied to the bundle root; + * every git-tracked file (with its working-tree contents), so `git add` + anything new that must ship; the project-root .mcpbignore then drops what + the server does not need at runtime (tests, CI, deployment files, this + folder, ...); + * engines/, regenerated from the SerpApi Playground with build-engines.py + (as the Dockerfile does), whether or not it has been committed. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +import threading +import tomllib +import zipfile +from pathlib import Path + +MCPB_DIR = Path(__file__).resolve().parent +ROOT = MCPB_DIR.parent +MANIFEST = MCPB_DIR / "manifest.json" +MCPBIGNORE = ROOT / ".mcpbignore" +DIST_DIR = ROOT / "dist" +ENGINES_DIR = "engines" +MCPB_CLI = "@anthropic-ai/mcpb@2.1.2" +SMOKE_TIMEOUT_SECONDS = 600 # first run may download a Python and all wheels + +# Files the bundle cannot work without, and prefixes that must never ship. +REQUIRED_ENTRIES = { + "manifest.json", + "pyproject.toml", + "uv.lock", + ".python-version", + "src/stdio.py", + "src/server.py", + "engines/google.json", +} +FORBIDDEN_PREFIXES = ( + ".venv/", + ".env", + ".github/", + "tests/", + "dist/", + "mcpb/", + "server/lib/", + "server/venv/", +) + + +def fail(message: str) -> None: + print(f"error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess: + print("+", " ".join(cmd), flush=True) + return subprocess.run(cmd, check=True, **kwargs) # type: ignore[call-overload] + + +def project_version() -> str: + with (ROOT / "pyproject.toml").open("rb") as pyproject: + return tomllib.load(pyproject)["project"]["version"] + + +def load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def check_manifest(manifest: dict, version: str) -> None: + """Fail early on the mistakes a packed bundle would only reveal at install time.""" + registry_version = load_json(ROOT / "server.json")["version"] + for name, other in ( + ("mcpb/manifest.json", manifest["version"]), + ("server.json", registry_version), + ): + if other != version: + fail( + f"pyproject.toml is {version} but {name} is {other}; " + "keep pyproject.toml, server.json and mcpb/manifest.json aligned" + ) + + server = manifest["server"] + if server["type"] != "uv": + fail( + "manifest server.type must be 'uv'; this script does not vendor dependencies" + ) + entry_point = server["entry_point"] + if not (ROOT / entry_point).is_file(): + fail(f"manifest entry_point {entry_point!r} does not exist") + if entry_point not in server["mcp_config"].get("args", []): + fail(f"manifest mcp_config.args does not launch entry_point {entry_point!r}") + + +def tracked_files() -> list[Path]: + listing = subprocess.run( + ["git", "ls-files", "-z"], + cwd=ROOT, + check=True, + capture_output=True, + ).stdout + return [Path(name) for name in listing.decode().split("\0") if name] + + +def stage_sources(staging: Path) -> int: + """Stage every git-tracked file, plus manifest.json at the root.""" + staging.mkdir(parents=True) + count = 0 + for relative in tracked_files(): + if relative.parts[0] == ENGINES_DIR: # populated by stage_engines instead + continue + source = ROOT / relative + if not source.is_file(): # deleted locally but not yet `git rm`-ed + continue + target = staging / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + count += 1 + # The MCPB CLI expects both at the root of the directory it packs. + # .mcpbignore is git-tracked at the project root, so the loop above already + # staged it; copying it again makes the build fail loudly if it ever goes + # missing instead of silently packing the whole tree. + shutil.copy2(MANIFEST, staging / "manifest.json") + shutil.copy2(MCPBIGNORE, staging / ".mcpbignore") + return count + + +def stage_engines(staging: Path, rebuild: bool) -> int: + """Populate ``staging/engines`` with every engine schema, committed or not.""" + target = staging / ENGINES_DIR + if rebuild: + # build-engines.py writes to ./engines relative to its cwd, so run it + # inside the staging dir with the project's environment; the repo's own + # engines/ directory is left untouched. + try: + run( + [ + uv(), + "run", + "--project", + str(ROOT), + "--frozen", + "--no-dev", + str(ROOT / "build-engines.py"), + ], + cwd=staging, + ) + except subprocess.CalledProcessError: + fail( + "rebuilding the engine schemas failed (it needs network access to " + "serpapi.com); pass --no-rebuild-engines to bundle engines/ from " + "the working tree instead" + ) + else: + target.mkdir(parents=True, exist_ok=True) + for schema in sorted((ROOT / ENGINES_DIR).glob("*.json")): + shutil.copy2(schema, target / schema.name) + + count = len(list(target.glob("*.json"))) + if count == 0: + fail("no engine schemas to bundle") + return count + + +def which(tool: str, hint: str) -> str: + path = shutil.which(tool) + if not path: + fail(f"{tool} not found; {hint}") + return path + + +def npx() -> str: + return which("npx", "install Node.js to run the MCPB CLI") + + +def uv() -> str: + return which("uv", "install uv (https://docs.astral.sh/uv/)") + + +def verify_archive(bundle: Path) -> list[str]: + with zipfile.ZipFile(bundle) as archive: + names = archive.namelist() + missing = sorted(REQUIRED_ENTRIES - set(names)) + if missing: + fail(f"bundle is missing required files: {', '.join(missing)}") + leaked = sorted(name for name in names if name.startswith(FORBIDDEN_PREFIXES)) + if leaked: + fail(f"bundle contains files that must not ship: {', '.join(leaked[:10])}") + return names + + +def substitute(value: str, variables: dict[str, str]) -> str: + for key, replacement in variables.items(): + value = value.replace("${" + key + "}", replacement) + return value + + +def mcp_handshake(proc: subprocess.Popen) -> tuple[dict, list[str]]: + """Speak just enough MCP over stdio to initialize and list tools.""" + assert proc.stdin is not None and proc.stdout is not None + + def send(message: dict) -> None: + proc.stdin.write(json.dumps(message) + "\n") + proc.stdin.flush() + + def request(request_id: int, method: str, params: dict | None = None) -> dict: + send( + { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params or {}, + } + ) + while True: + line = proc.stdout.readline() + if not line: + raise RuntimeError(f"server exited before answering {method}") + try: + reply = json.loads(line) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"non-JSON output on stdout while waiting for {method}: {line.strip()!r}" + ) from exc + if reply.get("id") != request_id: + continue # notification or unrelated message + if "error" in reply: + raise RuntimeError(f"{method} failed: {reply['error']}") + return reply["result"] + + init = request( + 1, + "initialize", + { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "mcpb-build", "version": "0"}, + }, + ) + send({"jsonrpc": "2.0", "method": "notifications/initialized"}) + tools = request(2, "tools/list") + return init, [tool["name"] for tool in tools["tools"]] + + +def smoke_test(bundle: Path, manifest: dict) -> None: + """Install the packed bundle into a temp dir and start it exactly as a host would.""" + uv() # the manifest command is `uv run ...` + + with tempfile.TemporaryDirectory(prefix="serpapi-mcpb-") as tmp: + install_dir = Path(tmp) / "bundle" + with zipfile.ZipFile(bundle) as archive: + archive.extractall(install_dir) + + variables = { + "__dirname": str(install_dir), + "user_config.serpapi_api_key": "smoke-test-key", + } + mcp_config = manifest["server"]["mcp_config"] + command = [substitute(mcp_config["command"], variables)] + command += [substitute(arg, variables) for arg in mcp_config.get("args", [])] + env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} + env.update( + {k: substitute(v, variables) for k, v in mcp_config.get("env", {}).items()} + ) + + print("+", " ".join(command), flush=True) + stderr_log = Path(tmp) / "server.stderr" + with stderr_log.open("w+", encoding="utf-8") as stderr: + proc = subprocess.Popen( + command, + cwd=tmp, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=stderr, + text=True, + ) + watchdog = threading.Timer(SMOKE_TIMEOUT_SECONDS, proc.kill) + watchdog.start() + try: + init, tools = mcp_handshake(proc) + except Exception as exc: + proc.kill() + stderr.seek(0) + print(stderr.read(), file=sys.stderr) + fail(f"smoke test failed: {exc}") + finally: + watchdog.cancel() + if proc.stdin: + proc.stdin.close() # EOF: the server shuts down cleanly + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() + + if "search" not in tools: + fail(f"smoke test: bundle does not expose the search tool (got {tools})") + info = init.get("serverInfo", {}) + print( + f"smoke test OK: {info.get('name')} {info.get('version')} " + f"started over stdio and lists tools {tools}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument( + "--no-rebuild-engines", + action="store_true", + help="bundle engines/ from the working tree instead of regenerating it", + ) + parser.add_argument( + "--no-smoke", + action="store_true", + help="skip installing and starting the packed bundle", + ) + args = parser.parse_args() + + version = project_version() + manifest = load_json(MANIFEST) + check_manifest(manifest, version) + + DIST_DIR.mkdir(exist_ok=True) + bundle = DIST_DIR / f"{manifest['name']}-{version}.mcpb" + bundle.unlink(missing_ok=True) + + with tempfile.TemporaryDirectory(prefix="serpapi-mcpb-src-") as tmp: + staging = Path(tmp) / manifest["name"] + staged = stage_sources(staging) + engines = stage_engines(staging, rebuild=not args.no_rebuild_engines) + source = "rebuilt from the SerpApi Playground" + if args.no_rebuild_engines: + source = "copied from the working tree" + print( + f"staged {staged} git-tracked files and {engines} engine schemas ({source})" + ) + npx_bin = npx() + run([npx_bin, "--yes", MCPB_CLI, "validate", "manifest.json"], cwd=staging) + run([npx_bin, "--yes", MCPB_CLI, "pack", str(staging), str(bundle)], cwd=tmp) + + names = verify_archive(bundle) + size_kib = bundle.stat().st_size / 1024 + print( + f"built {bundle.relative_to(ROOT)} " + f"({size_kib:.0f} KiB, {len(names)} files, {engines} engines)" + ) + + if not args.no_smoke: + smoke_test(bundle, manifest) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mcpb/manifest.json b/mcpb/manifest.json new file mode 100644 index 0000000..0068b41 --- /dev/null +++ b/mcpb/manifest.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://raw.githubusercontent.com/modelcontextprotocol/mcpb/main/schemas/mcpb-manifest-v0.4.schema.json", + "manifest_version": "0.4", + "name": "serpapi-mcp", + "display_name": "SerpApi", + "version": "1.0.2", + "description": "Official SerpApi MCP server for Google, Bing, and other search engines.", + "long_description": "Live, structured search results from SerpApi inside your MCP host: web, news, images, shopping, jobs, local businesses, flights, hotels, videos, scholar, app stores and more, across Google, Bing, Yahoo, DuckDuckGo, YouTube, eBay, Amazon and other engines.\n\nEvery engine's parameters are exposed as MCP resources (`serpapi://engines`), results come back as JSON or token-efficient Markdown, and hosts that support MCP Apps get interactive table and dashboard views.\n\nRequires a SerpApi API key: https://serpapi.com/manage-api-key", + "author": { + "name": "SerpApi", + "url": "https://serpapi.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/serpapi/serpapi-mcp" + }, + "homepage": "https://serpapi.com/", + "documentation": "https://github.com/serpapi/serpapi-mcp#readme", + "support": "https://github.com/serpapi/serpapi-mcp/issues", + "server": { + "type": "uv", + "entry_point": "src/stdio.py", + "mcp_config": { + "command": "uv", + "args": [ + "run", + "--directory", + "${__dirname}", + "--frozen", + "--no-dev", + "src/stdio.py" + ], + "env": { + "SERPAPI_API_KEY": "${user_config.serpapi_api_key}" + } + } + }, + "tools": [ + { + "name": "search", + "description": "Search any SerpApi engine (Google, Bing, YouTube, Amazon, eBay and more) and return results as JSON or Markdown." + }, + { + "name": "search_table", + "description": "Render organic results as an interactive, sortable table (hosts with MCP Apps support)." + }, + { + "name": "search_dashboard", + "description": "Render results as an interactive dashboard with metrics, a source chart and result details (hosts with MCP Apps support)." + } + ], + "keywords": [ + "serpapi", + "search", + "web-search", + "serp", + "google", + "bing", + "duckduckgo", + "youtube", + "amazon", + "ebay", + "news", + "shopping" + ], + "license": "MIT", + "privacy_policies": [ + "https://serpapi.com/legal#privacy-policy" + ], + "compatibility": { + "platforms": [ + "darwin", + "win32", + "linux" + ], + "runtimes": { + "python": ">=3.12" + } + }, + "user_config": { + "serpapi_api_key": { + "type": "string", + "title": "SerpApi API Key", + "description": "Your SerpApi API key. Get one at https://serpapi.com/manage-api-key", + "sensitive": true, + "required": true + } + } +} diff --git a/server.json b/server.json index dabd494..aecb6fe 100644 --- a/server.json +++ b/server.json @@ -12,15 +12,24 @@ "remotes": [ { "type": "streamable-http", - "url": "https://mcp.serpapi.com/{SERPAPI_API_KEY}/mcp", - "variables": { - "SERPAPI_API_KEY": { - "description": "Your SerpApi API key. Get one at https://serpapi.com/manage-api-key", + "url": "https://mcp.serpapi.com/mcp", + "headers": [ + { + "name": "Authorization", + "description": "Bearer token authentication using your SerpApi API key.", "isRequired": true, "isSecret": true, - "placeholder": "your-serpapi-api-key" + "value": "Bearer {SERPAPI_API_KEY}", + "variables": { + "SERPAPI_API_KEY": { + "description": "Your SerpApi API key. Get one at https://serpapi.com/manage-api-key", + "isRequired": true, + "isSecret": true, + "placeholder": "your-serpapi-api-key" + } + } } - } + ] } ] } diff --git a/src/mcp_components/tools.py b/src/mcp_components/tools.py index acebafd..e7446bb 100644 --- a/src/mcp_components/tools.py +++ b/src/mcp_components/tools.py @@ -1,4 +1,5 @@ import json +import os from typing import Any import serpapi @@ -59,7 +60,8 @@ def map_search_error(exception) -> str: if "401" in text: return ( "Error: Invalid SerpApi API key. " - "Check your API key in the path or Authorization header." + "Check the key in the request path or Authorization header, " + "or in SERPAPI_API_KEY for stdio hosts." ) if "403" in text: return ( @@ -192,12 +194,30 @@ async def search(params: dict[str, Any] = None, mode: str = "complete") -> str: return map_search_error(e) -def fetch_search_response(params: dict[str, Any] | None) -> SerpResults | str: - """Run a SerpApi search using the request's API key. Raises on failure.""" - request = get_http_request() +def resolve_api_key() -> str | None: + """Return the SerpApi key for the current call. + + Over HTTP the key is attached to the request by ``ApiKeyMiddleware`` and + always wins. Local stdio hosts (e.g. the Claude Desktop MCP Bundle) have no + HTTP request at all, so fall back to the ``SERPAPI_API_KEY`` environment + variable. + """ + try: + request = get_http_request() + except RuntimeError: # no HTTP request: running over stdio + request = None api_key = getattr(getattr(request, "state", None), "api_key", None) + return api_key or os.getenv("SERPAPI_API_KEY") or None + + +def fetch_search_response(params: dict[str, Any] | None) -> SerpResults | str: + """Run a SerpApi search using the caller's API key. Raises on failure.""" + api_key = resolve_api_key() if not api_key: - raise RuntimeError("Error: Unable to access API key from request context") + raise RuntimeError( + "Error: Unable to access API key from request context " + "or SERPAPI_API_KEY environment variable" + ) # api_key set last so caller params can never override the trusted key. search_params = { diff --git a/src/server.py b/src/server.py index 5e9134e..e0df323 100644 --- a/src/server.py +++ b/src/server.py @@ -132,7 +132,6 @@ async def healthcheck_handler(request): middleware = [ Middleware(RequestMetricsMiddleware), - Middleware(ApiKeyMiddleware), Middleware( CORSMiddleware, allow_origins=["*"], @@ -140,6 +139,7 @@ async def healthcheck_handler(request): allow_methods=["*"], allow_headers=["*"], ), + Middleware(ApiKeyMiddleware), ] starlette_app = mcp.http_app( middleware=middleware, stateless_http=True, json_response=True diff --git a/src/stdio.py b/src/stdio.py new file mode 100644 index 0000000..17ebc87 --- /dev/null +++ b/src/stdio.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Stdio entry point for local MCP hosts such as the Claude Desktop MCP Bundle. + +The hosted deployment (``src/server.py``) serves MCP over HTTP and reads the +SerpApi key from every request. Local hosts launch the server as a subprocess +and talk to it over stdin/stdout instead, so this entry point reads the key +from the ``SERPAPI_API_KEY`` environment variable (wired up in ``manifest.json``). + +Run it directly with:: + + SERPAPI_API_KEY=... uv run src/stdio.py +""" + +import sys +from pathlib import Path + +# ``uv run src/stdio.py`` puts ``src/`` on sys.path rather than the project +# root, so add the root explicitly to make the ``src.*`` imports resolve no +# matter where the bundle is installed or which directory the host starts from. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.server import mcp # noqa: E402 + +if __name__ == "__main__": + # stdout carries the MCP protocol; keep the process quiet on stderr too so + # host logs stay readable. + mcp.run(transport="stdio", show_banner=False) diff --git a/tests/test_mcpb.py b/tests/test_mcpb.py new file mode 100644 index 0000000..ab90196 --- /dev/null +++ b/tests/test_mcpb.py @@ -0,0 +1,79 @@ +"""Keeps the MCP Bundle manifest (mcpb/manifest.json) and the stdio entry point honest. + +Claude Desktop installs the server from mcpb/manifest.json and starts src/stdio.py +over stdio with the API key in SERPAPI_API_KEY, so these tests pin the manifest +to what the server actually exposes and start the entry point for real. +""" + +import json +import os +import tomllib +from pathlib import Path + +from fastmcp import Client +from fastmcp.client.transports import PythonStdioTransport + +import src.server as server + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = json.loads((ROOT / "mcpb" / "manifest.json").read_text(encoding="utf-8")) + + +def test_manifest_version_matches_pyproject_and_registry(): + with (ROOT / "pyproject.toml").open("rb") as pyproject: + project_version = tomllib.load(pyproject)["project"]["version"] + registry_version = json.loads((ROOT / "server.json").read_text())["version"] + assert MANIFEST["version"] == project_version == registry_version + + +def test_mcpbignore_sits_at_project_root_and_drops_non_runtime_files(): + # The MCPB CLI only honours .mcpbignore at the root of the packed directory. + patterns = { + line.strip() + for line in (ROOT / ".mcpbignore").read_text(encoding="utf-8").splitlines() + if line.strip() and not line.startswith("#") + } + assert {"tests/", ".github/", "mcpb/", ".env", ".venv/"} <= patterns + + +def test_manifest_uses_uv_runtime_and_launches_existing_entry_point(): + manifest_server = MANIFEST["server"] + assert MANIFEST["manifest_version"] == "0.4" # first version with the uv runtime + assert manifest_server["type"] == "uv" + entry_point = manifest_server["entry_point"] + assert (ROOT / entry_point).is_file() + args = manifest_server["mcp_config"]["args"] + assert manifest_server["mcp_config"]["command"] == "uv" + assert args[:3] == ["run", "--directory", "${__dirname}"] + assert args[-1] == entry_point + + +def test_manifest_wires_api_key_from_user_config_into_env(): + env = MANIFEST["server"]["mcp_config"]["env"] + assert env == {"SERPAPI_API_KEY": "${user_config.serpapi_api_key}"} + option = MANIFEST["user_config"]["serpapi_api_key"] + assert option["type"] == "string" + assert option["required"] is True + assert option["sensitive"] is True + + +async def test_manifest_tools_match_server_tools(): + server_tools = {tool.name for tool in await server.mcp.list_tools()} + assert {tool["name"] for tool in MANIFEST["tools"]} == server_tools + + +async def test_stdio_entry_point_serves_tools_and_resources(tmp_path): + # Start from an unrelated cwd so only the entry point's own sys.path + # bootstrap can make the `src.*` imports resolve, as in an installed bundle. + transport = PythonStdioTransport( + ROOT / MANIFEST["server"]["entry_point"], + env={**os.environ, "SERPAPI_API_KEY": "test-key"}, + cwd=str(tmp_path), + keep_alive=False, + ) + async with Client(transport) as client: + tools = {tool.name for tool in await client.list_tools()} + engines = await client.read_resource("serpapi://engines") + + assert {"search", "search_table", "search_dashboard"} <= tools + assert json.loads(engines[0].text)["count"] > 0 diff --git a/tests/test_server.py b/tests/test_server.py index dc7828f..b3a5307 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -225,8 +225,54 @@ async def test_search_without_api_key_returns_graceful_error(monkeypatch): # A real starlette Request with empty state: request.state.api_key would raise # AttributeError, so the guard must use getattr, not attribute access. use_request(monkeypatch, real_request(state={})) + monkeypatch.delenv("SERPAPI_API_KEY", raising=False) out = await mcp_tools.search(params={"q": "x"}) - assert out == "Error: Unable to access API key from request context" + assert out == ( + "Error: Unable to access API key from request context " + "or SERPAPI_API_KEY environment variable" + ) + + +def no_http_request(): + # What fastmcp raises when the server runs over stdio (no HTTP request). + raise RuntimeError("No active HTTP request found.") + + +async def test_search_falls_back_to_env_api_key_over_stdio(monkeypatch): + captured = {} + + def fake_search(params): + captured.update(params) + return serp_results({"organic_results": []}) + + monkeypatch.setattr(mcp_tools, "get_http_request", no_http_request) + monkeypatch.setenv("SERPAPI_API_KEY", "ENVKEY") + use_search(monkeypatch, fake_search) + + await mcp_tools.search(params={"q": "x"}) + assert captured["api_key"] == "ENVKEY" + + +async def test_request_api_key_takes_precedence_over_env(monkeypatch): + captured = {} + + def fake_search(params): + captured.update(params) + return serp_results({"organic_results": []}) + + use_request(monkeypatch, real_request(state={"api_key": "REQUEST"})) + monkeypatch.setenv("SERPAPI_API_KEY", "ENVKEY") + use_search(monkeypatch, fake_search) + + await mcp_tools.search(params={"q": "x"}) + assert captured["api_key"] == "REQUEST" + + +async def test_search_over_stdio_without_env_key_returns_graceful_error(monkeypatch): + monkeypatch.setattr(mcp_tools, "get_http_request", no_http_request) + monkeypatch.delenv("SERPAPI_API_KEY", raising=False) + out = await mcp_tools.search(params={"q": "x"}) + assert out.startswith("Error: Unable to access API key") async def test_search_complete_returns_full_payload(monkeypatch): @@ -600,6 +646,7 @@ async def test_search_dashboard_returns_dashboard_app(monkeypatch): async def test_search_table_without_api_key_renders_error_app(monkeypatch): use_request(monkeypatch, real_request(state={})) + monkeypatch.delenv("SERPAPI_API_KEY", raising=False) app = await mcp_apps.search_table(params={"q": "x"}) assert app.title == "Search error" assert "Unable to access API key" in ui_json(app)