diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml index 7517098..3821ef4 100644 --- a/.github/workflows/release-mcp.yml +++ b/.github/workflows/release-mcp.yml @@ -65,3 +65,54 @@ jobs: with: packages-dir: dist/ attestations: true + + build-mcpb: + name: Build MCPB bundle + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + + # ``scripts/build_mcpb.py`` is stdlib-only and shells out to the ``mcpb`` + # CLI — no project deps are needed for this job. Use setup-python + # instead of setup-uv so we don't pull in a project venv resolution we + # don't use. + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install Node.js (for mcpb CLI) + uses: actions/setup-node@v5 + with: + node-version: "20" + + - name: Install mcpb CLI + run: npm install -g @anthropic-ai/mcpb + + - name: Build .mcpb bundle + id: build + run: | + artifact=$(python scripts/build_mcpb.py | tail -n 1) + echo "artifact=${artifact}" >> "$GITHUB_OUTPUT" + ls -la "${artifact}" + + - name: Upload .mcpb artifact + uses: actions/upload-artifact@v7 + with: + name: mcpb + path: ${{ steps.build.outputs.artifact }} + + - name: Attach .mcpb to GitHub release + # The release workflow triggers on ``mcp-v*`` tags; semantic-release + # creates the GitHub release in an earlier job. Upload the .mcpb as + # an additional asset on the release matching the tag that + # triggered this run. + if: startsWith(github.ref, 'refs/tags/mcp-v') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${GITHUB_REF#refs/tags/}" + gh release upload "$tag" "${{ steps.build.outputs.artifact }}" --clobber diff --git a/frontapp_mcp_server/README.md b/frontapp_mcp_server/README.md index 3361458..dfd9e48 100644 --- a/frontapp_mcp_server/README.md +++ b/frontapp_mcp_server/README.md @@ -62,7 +62,21 @@ FRONTAPP_BASE_URL=https://api2.frontapp.com # optional override ### 4. Use with Claude Desktop (stdio) -Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: +**Recommended: install the `.mcpb` bundle** — Claude Desktop has built-in support for +[MCP Bundles](https://github.com/anthropics/mcpb), which install local MCP servers in +one click and prompt for the API key via UI (no JSON editing). + +1. Download `frontapp-mcp-server-.mcpb` from the + [latest GitHub release](https://github.com/dougborg/frontapp-openapi-client/releases?q=mcp-v). +1. Drag the `.mcpb` file into Claude Desktop, or open it from the Finder. +1. Confirm install in the dialog. Claude Desktop prompts for your Frontapp API key + (stored securely; never written to a config file by hand). + +The bundle ships the server source plus a manifest that declares the runtime +requirements; UV handles dep resolution on first launch. + +**Manual `uvx` install (fallback)** — if you'd rather edit +`~/Library/Application Support/Claude/claude_desktop_config.json` directly: ```json { @@ -78,11 +92,11 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: } ``` -Restart Claude Desktop and the Frontapp tools will appear. +Either path: restart Claude Desktop and the Frontapp tools will appear. > **Running off a local checkout (dev mode)?** The package isn't on PyPI yet (issue -> #10), and `uvx` resolves from PyPI. To dogfood from a local clone, see -> [MCP_CLAUDE_DESKTOP_DEV.md](MCP_CLAUDE_DESKTOP_DEV.md) — it covers the +> #10), and both the `.mcpb` bundle and `uvx` resolve from PyPI. To dogfood from a local +> clone, see [MCP_CLAUDE_DESKTOP_DEV.md](MCP_CLAUDE_DESKTOP_DEV.md) — it covers the > `uv run --directory ` config + `.env` setup. ### 5. Use with Claude.ai (streamable-http) diff --git a/frontapp_mcp_server/mcpb/.mcpbignore b/frontapp_mcp_server/mcpb/.mcpbignore new file mode 100644 index 0000000..e85d5d5 --- /dev/null +++ b/frontapp_mcp_server/mcpb/.mcpbignore @@ -0,0 +1,18 @@ +# Files excluded from the .mcpb archive when ``mcpb pack`` runs against the +# build/mcpb/ staging directory. The CLI already excludes a default set +# (.git, node_modules, .DS_Store, .vscode, etc.); this list adds project- +# specific noise. +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +.coverage.* +htmlcov/ +.venv/ +*.egg-info/ +build/ +dist/ +tests/ diff --git a/frontapp_mcp_server/mcpb/manifest.template.json b/frontapp_mcp_server/mcpb/manifest.template.json new file mode 100644 index 0000000..fb4ceb8 --- /dev/null +++ b/frontapp_mcp_server/mcpb/manifest.template.json @@ -0,0 +1,54 @@ +{ + "manifest_version": "0.4", + "name": "frontapp-mcp-server", + "display_name": "Frontapp", + "version": "__VERSION__", + "description": "MCP server for the Front shared-inbox API — search conversations, read messages, and draft replies from Claude Desktop.", + "long_description": "Exposes Front's Core API (conversations, messages, comments, drafts) as MCP tools. Includes cursor-paginated list/search, full message and comment read tools, two-step confirmation on every mutation, and a drafts-first outbound flow (agents draft, humans send from Front's UI). Transport layer handles retries, 429 rate limits, and cursor pagination.", + "author": { + "name": "Doug Borg", + "email": "dougborg@dougborg.org", + "url": "https://github.com/dougborg" + }, + "repository": { + "type": "git", + "url": "https://github.com/dougborg/frontapp-openapi-client.git" + }, + "homepage": "https://github.com/dougborg/frontapp-openapi-client", + "documentation": "https://dougborg.github.io/frontapp-openapi-client/", + "support": "https://github.com/dougborg/frontapp-openapi-client/issues", + "license": "MIT", + "keywords": ["frontapp", "front", "conversations", "mcp", "claude", "shared-inbox"], + "server": { + "type": "uv", + "entry_point": "src/frontapp_mcp/__main__.py", + "mcp_config": { + "command": "uv", + "args": [ + "run", + "--directory", + "${__dirname}", + "frontapp-mcp-server" + ], + "env": { + "FRONTAPP_API_KEY": "${user_config.api_key}" + } + } + }, + "compatibility": { + "claude_desktop": ">=0.10.0", + "platforms": ["darwin", "linux", "win32"], + "runtimes": { + "python": ">=3.12,<4.0" + } + }, + "user_config": { + "api_key": { + "type": "string", + "title": "Frontapp API Key", + "description": "Front API token. Create one at Front → Settings → Developers → API tokens.", + "required": true, + "sensitive": true + } + } +} diff --git a/frontapp_mcp_server/mcpb/pyproject.template.toml b/frontapp_mcp_server/mcpb/pyproject.template.toml new file mode 100644 index 0000000..6a6d5a3 --- /dev/null +++ b/frontapp_mcp_server/mcpb/pyproject.template.toml @@ -0,0 +1,34 @@ +# Bundle pyproject.toml for the .mcpb artifact — do NOT edit this directly to +# add deps. The build script (scripts/build_mcpb.py) substitutes ``__VERSION__`` +# from the package's own pyproject.toml at pack time. Production runtime deps +# are mirrored from ``frontapp_mcp_server/pyproject.toml`` and must stay in +# sync; the build script verifies this and fails loudly on drift. +# +# This file deliberately omits the ``[tool.uv.sources]`` workspace ref — the +# bundle resolves ``frontapp-openapi-client`` from PyPI, since the workspace +# doesn't exist on the user's machine. +[project] +name = "frontapp-mcp-server" +version = "__VERSION__" +description = "MCP server for the Frontapp API" +requires-python = ">=3.12" +dependencies = [ + "fastmcp>=2.13.0", + "frontapp-openapi-client>=0.1.0", + "prefab-ui>=0.19,<0.20", + "pydantic>=2.12.0", + "python-dotenv>=1.0.0", + "structlog>=25.5.0", + "aiosqlite>=0.20.0", + "platformdirs>=4.0.0", +] + +[project.scripts] +frontapp-mcp-server = "frontapp_mcp.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/frontapp_mcp"] diff --git a/frontapp_mcp_server/pyproject.toml b/frontapp_mcp_server/pyproject.toml index f58e34a..1f69dea 100644 --- a/frontapp_mcp_server/pyproject.toml +++ b/frontapp_mcp_server/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ ] dependencies = [ "fastmcp>=2.13.0", - "frontapp-openapi-client>=0.51.0", + "frontapp-openapi-client>=0.1.0", "prefab-ui>=0.19,<0.20", "pydantic>=2.12.0", "python-dotenv>=1.0.0", diff --git a/pyproject.toml b/pyproject.toml index f4d5cb9..3b64076 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -469,6 +469,15 @@ regenerate-all = ["vendor-spec", "regenerate-client", "facts"] validate-openapi = "openapi-spec-validator docs/frontapp-openapi.yaml" validate-openapi-redocly = "npx @redocly/cli lint docs/frontapp-openapi.yaml" +# ----------------------------------------------------------------------------- +# MCP Bundle (.mcpb) Build +# ----------------------------------------------------------------------------- +# Produces ``dist/frontapp-mcp-server-.mcpb`` for one-click install +# in Claude Desktop. Requires the ``mcpb`` CLI on PATH: +# npm install -g @anthropic-ai/mcpb +# CI installs it on demand; locally, install once with the command above. +build-mcpb = "python scripts/build_mcpb.py" + # ----------------------------------------------------------------------------- # Documentation Tasks # ----------------------------------------------------------------------------- diff --git a/scripts/build_mcpb.py b/scripts/build_mcpb.py new file mode 100644 index 0000000..93e9ab9 --- /dev/null +++ b/scripts/build_mcpb.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Build the Frontapp MCP server as an MCP Bundle (``.mcpb``). + +The MCP Bundle format (formerly DXT) is Claude Desktop's one-click install +package for local MCP servers. See https://github.com/anthropics/mcpb. + +Pipeline: + +1. Read the package version from ``frontapp_mcp_server/pyproject.toml``. +2. Stage a self-contained bundle directory under ``build/mcpb/`` containing: + + - ``manifest.json`` — derived from + ``frontapp_mcp_server/mcpb/manifest.template.json`` with ``__VERSION__`` + substituted. + - ``pyproject.toml`` — derived from + ``frontapp_mcp_server/mcpb/pyproject.template.toml`` with ``__VERSION__`` + substituted; production deps mirrored from the package's own + ``pyproject.toml`` minus the workspace source ref. Mirroring is + verified — drift between the two files fails the build loudly. + - ``.mcpbignore`` — copied as-is. + - ``src/frontapp_mcp/`` — copied from + ``frontapp_mcp_server/src/frontapp_mcp/``. + - ``README.md`` — copied from the package's own README. + +3. Validate ``manifest.json`` against the MCPB schema via ``mcpb validate``. +4. Run ``mcpb pack build/mcpb dist/frontapp-mcp-server-.mcpb`` to + produce the final ``.mcpb`` artifact. + +Requires the ``mcpb`` CLI on PATH (``npm install -g @anthropic-ai/mcpb``). +Outputs the artifact path on stdout — release CI uses that as the upload path. + +Env: ``MCPB_SKIP_PACK=1`` stages the bundle but skips ``mcpb pack`` (handy when +the CLI isn't installed locally — CI installs it on demand). +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +PKG_ROOT = REPO_ROOT / "frontapp_mcp_server" +PKG_PYPROJECT = PKG_ROOT / "pyproject.toml" +PKG_SRC = PKG_ROOT / "src" / "frontapp_mcp" +PKG_README = PKG_ROOT / "README.md" + +MCPB_DIR = PKG_ROOT / "mcpb" +MANIFEST_TEMPLATE = MCPB_DIR / "manifest.template.json" +PYPROJECT_TEMPLATE = MCPB_DIR / "pyproject.template.toml" +MCPBIGNORE = MCPB_DIR / ".mcpbignore" + +BUILD_DIR = REPO_ROOT / "build" / "mcpb" +DIST_DIR = REPO_ROOT / "dist" + +VERSION_PLACEHOLDER = "__VERSION__" + + +def read_pkg_pyproject() -> dict[str, Any]: + with PKG_PYPROJECT.open("rb") as f: + return tomllib.load(f) + + +def get_pkg_version(pyproject: dict[str, Any]) -> str: + version = pyproject.get("project", {}).get("version") + if not isinstance(version, str) or not version: + raise RuntimeError(f"Could not read [project.version] from {PKG_PYPROJECT}") + return version + + +def verify_dep_mirror(pkg_pyproject: dict[str, Any]) -> None: + """Fail loudly if the bundle pyproject's deps drift from the package's. + + The bundle template hand-copies the production deps so that we can omit the + workspace ``[tool.uv.sources]`` ref (which only resolves inside the + monorepo). When new deps are added to the package, the template must be + updated to match — otherwise the bundle would silently miss them at + runtime. + """ + with PYPROJECT_TEMPLATE.open("rb") as f: + bundle_pyproject = tomllib.load(f) + + pkg_deps = set(pkg_pyproject.get("project", {}).get("dependencies", [])) + bundle_deps = set(bundle_pyproject.get("project", {}).get("dependencies", [])) + + missing_from_bundle = pkg_deps - bundle_deps + extra_in_bundle = bundle_deps - pkg_deps + + if missing_from_bundle or extra_in_bundle: + msg = ["Bundle pyproject.template.toml is out of sync with the package's."] + if missing_from_bundle: + msg.append(" Missing from bundle (add to template):") + msg.extend(f" - {dep}" for dep in sorted(missing_from_bundle)) + if extra_in_bundle: + msg.append(" Extra in bundle (remove from template):") + msg.extend(f" - {dep}" for dep in sorted(extra_in_bundle)) + raise RuntimeError("\n".join(msg)) + + +def substitute(template: str, version: str) -> str: + return template.replace(VERSION_PLACEHOLDER, version) + + +def stage_bundle(version: str) -> None: + if BUILD_DIR.exists(): + shutil.rmtree(BUILD_DIR) + BUILD_DIR.mkdir(parents=True) + + # Force UTF-8 + LF on read and write — templates contain non-ASCII chars + # (em-dash, arrow). Default encoding is locale-dependent; on a non-UTF-8 + # locale (Windows ``cp1252``) the round-trip silently corrupts manifest + # text and the bundle ships with garbled metadata. + (BUILD_DIR / "manifest.json").write_text( + substitute(MANIFEST_TEMPLATE.read_text(encoding="utf-8"), version), + encoding="utf-8", + newline="\n", + ) + (BUILD_DIR / "pyproject.toml").write_text( + substitute(PYPROJECT_TEMPLATE.read_text(encoding="utf-8"), version), + encoding="utf-8", + newline="\n", + ) + shutil.copy2(MCPBIGNORE, BUILD_DIR / ".mcpbignore") + + src_dest = BUILD_DIR / "src" / "frontapp_mcp" + shutil.copytree( + PKG_SRC, + src_dest, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"), + ) + + if PKG_README.exists(): + shutil.copy2(PKG_README, BUILD_DIR / "README.md") + + +def run_mcpb_validate() -> None: + subprocess.run( + ["mcpb", "validate", str(BUILD_DIR / "manifest.json")], + check=True, + ) + + +def run_mcpb_pack(version: str) -> Path: + DIST_DIR.mkdir(exist_ok=True) + artifact = DIST_DIR / f"frontapp-mcp-server-{version}.mcpb" + if artifact.exists(): + artifact.unlink() + subprocess.run( + ["mcpb", "pack", str(BUILD_DIR), str(artifact)], + check=True, + ) + return artifact + + +def main() -> int: + pyproject = read_pkg_pyproject() + version = get_pkg_version(pyproject) + verify_dep_mirror(pyproject) + stage_bundle(version) + + if os.environ.get("MCPB_SKIP_PACK") == "1": + print(BUILD_DIR, file=sys.stdout) + return 0 + + run_mcpb_validate() + artifact = run_mcpb_pack(version) + print(artifact, file=sys.stdout) + return 0 + + +if __name__ == "__main__": + sys.exit(main())