diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e2a716..84e93e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,6 +177,14 @@ jobs: - name: Install MkDocs Material run: pip install mkdocs-material mkdocs-swagger-ui-tag + # packages/mcp is a Python client of the API, and this job already has Python. Its test pins + # the read-only tool surface against the committed OpenAPI snapshot (which the .NET job pins + # against the served document), so a write endpoint cannot leak into the MCP tool set unnoticed. + - name: Test the MCP server package + run: | + pip install "fastmcp>=3.4,<4" pytest + pytest packages/mcp -q + - name: Build the docs site working-directory: docs-site run: mkdocs build --strict diff --git a/.gitignore b/.gitignore index 6943bfc..e5a6149 100644 --- a/.gitignore +++ b/.gitignore @@ -210,6 +210,8 @@ PublishScripts/ *.snupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* +# ...but /packages/* at the repo root is our MIT client code (packages/mcp), not NuGet restore output. +!/packages/** # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f5e37a..3e2bdfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ descriptions are **document** versions, produced by the versioning engine. They ### Added +- **easydocs can be used from AI coding agents.** `packages/mcp/easydocs_mcp.py` is a read-only + [MCP](https://modelcontextprotocol.io) server generated from the install's own `/openapi/v1.json`: + seventeen tools covering documents, history, redlines, audit trails, approvals and folders, for + Claude Code, Cursor, Codex, Gemini CLI and Claude Desktop. It runs on the user's machine with their + own `ed_` token, so every call is authorized exactly as their `curl` would be. Nothing on the + server changed. `packages/*` is now real and MIT-licensed, as the licence section always said it + would be. - **Merging a branch now goes through a review.** The history's merge control opens a screen showing the version both branches forked from, what each side changed since that fork, and a hint naming the paragraphs both authors touched — so the decision is made with the other side's work visible, rather diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c44b041..585fab1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,9 +40,9 @@ By making a contribution to this project, I certify that: ## Licensing -- **Everything in this repository today is AGPL-3.0.** -- Future API clients / SDKs under `packages/*` will be **MIT** — but there is no `packages/` directory - yet, so nothing here is MIT-licensed at the moment. +- **Everything in this repository is AGPL-3.0, except `packages/*`.** +- `packages/*` holds API clients and is **MIT** — today that is `packages/mcp`, the MCP server. Each + package carries its own `LICENSE`. By contributing, you agree your contribution is licensed under the license of the directory it lands in. The full reasoning and the exact boundary are in the diff --git a/README.md b/README.md index 89099f4..5a44bcb 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,8 @@ Everything the UI does, the API does — it is the same surface, not a subset. - **Auth:** `ed_`-prefixed personal access tokens as `Authorization: Bearer ed_…`, or the session cookie for the browser. A token can never exceed the role of the user who minted it. - **Live updates:** server-sent events per document at `/api/v1/documents/{id}/events`. +- **MCP:** a read-only [MCP server](packages/mcp/) for Claude Code, Cursor and other AI coding agents, + generated from the OpenAPI document — install with one command, runs on your machine as you. Worked end-to-end examples: [automation recipes](https://aptsny.github.io/easydocs/automation-recipes/). @@ -198,18 +200,17 @@ way it started: people who need it, keeping it alive. ## License -**Everything in this repository today is AGPL-3.0** ([LICENSE](LICENSE)) — server, SPA, tests, deploy -files, docs. +**Everything in this repository is AGPL-3.0** ([LICENSE](LICENSE)) — server, SPA, tests, deploy +files, docs — except `packages/*`, which is MIT. | Path | License | |---|---| -| Everything in this repo | **AGPL-3.0** — the whole repository right now | -| `packages/*` — future API client SDKs | **MIT**, when written. The directory does not exist yet. | +| Everything outside `packages/*` | **AGPL-3.0** | +| `packages/*` — API clients | **MIT.** Today: [`packages/mcp`](packages/mcp/), the MCP server. | AGPL is the right licence for a self-hostable server — it keeps modifications to a *hosted* easydocs -available to its users. It is the wrong licence for a thin client library, so future SDKs will live -under `packages/*` with their own MIT `LICENSE`. **Until that directory exists, assume AGPL-3.0 for -anything you take from here.** Full reasoning: +available to its users. It is the wrong licence for a thin client library, so clients live +under `packages/*` with their own MIT `LICENSE`. **Anything outside that directory is AGPL-3.0.** Full reasoning: [spec §14](docs/superpowers/specs/2026-07-24-easydocs-v1-design.md). Contributions are under the **Developer Certificate of Origin** — sign off every commit with diff --git a/docs-site/docs/automation-recipes.md b/docs-site/docs/automation-recipes.md index 3e667f9..5676deb 100644 --- a/docs-site/docs/automation-recipes.md +++ b/docs-site/docs/automation-recipes.md @@ -251,6 +251,10 @@ or an Editor of the document may revoke. ## Other useful calls +Prefer talking to an AI agent instead of `curl`? The [MCP server](https://github.com/AptsNY/easydocs/tree/main/packages/mcp) +exposes the read side of this API — every `GET` below — as tools for Claude Code, Cursor and friends, +authenticated with the same `ed_` token. + ```bash # Who am I, and which org is this session bound to? curl -sS -H "$AUTH" "$BASE/api/v1/me" diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 576a79c..60870f2 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -208,19 +208,19 @@ trusting. Conformance criteria (E1–E12) encode the spec's promises as executab **Consequences.** Green means the artifact users pull works, on both architectures. Trade: negative guards age — two of them had to learn that v1.1 legitimately ships WebDAV. -## ADR-13: AGPL server, MIT SDKs (when they exist) +## ADR-13: AGPL server, MIT clients under `packages/*` **Context.** A self-hostable server wants copyleft (improvements to *hosted* instances must flow back); client libraries want the opposite (copyleft on an SDK punishes the API's own users). **Decision.** Everything in the repository is AGPL-3.0. Future client SDKs will live under -`packages/*` with their own MIT license — a directory boundary you can point at. Until that -directory exists, nothing here is MIT. Contributions are DCO (`git commit -s`), no CLA; +`packages/*` with their own MIT license — a directory boundary you can point at. The first occupant +is `packages/mcp`, the MCP server (2026-08). Contributions are DCO (`git commit -s`), no CLA; contributors keep their copyright. **Consequences.** A company can run, modify, and even sell hosted easydocs — as long as its users get the source of what's actually serving them. Integrating with the API from proprietary code is -explicitly intended and will be MIT-smooth once SDKs exist. +explicitly intended and is MIT where the client code lives. --- diff --git a/docs/superpowers/plans/2026-08-26-mcp-server.md b/docs/superpowers/plans/2026-08-26-mcp-server.md new file mode 100644 index 0000000..634c821 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-mcp-server.md @@ -0,0 +1,529 @@ +# MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `packages/mcp/easydocs_mcp.py` — a read-only MCP server generated from easydocs' own OpenAPI document, run locally per person with their `ed_` token — plus its test, README, licence flip and CI hook. + +**Architecture:** One PEP 723 Python script calls `FastMCP.from_openapi()` on `${EASYDOCS_URL}/openapi/v1.json` with an ordered `route_maps` allowlist (deny-all last) and a `mcp_component_fn` that renames the seventeen surviving GET routes from a dict. One pytest file builds the same server from the committed OpenAPI snapshot and asserts the exact tool set and that every tool is a GET. **No change to `src/`** — the naming fork in the spec was resolved by a scratch run: FastMCP 3.4.7's `mcp_component_fn` receives `route.method`/`route.path` and a settable `component.name` even when `operationId` is null. + +**Tech Stack:** Python ≥3.10, `fastmcp>=3.4,<4` (verified 3.4.7), `httpx` (transitive), `uv` for running, `pytest` for the test. Spec: `docs/superpowers/specs/2026-08-26-mcp-server-design.md`. + +**Branch:** `feat/mcp-server` (already created; spec commits are on it). Every commit `git commit -s` (DCO). CI must stay green: `docs-build` gains the pytest step. + +--- + +## File structure + +| Path | Responsibility | +|---|---| +| `packages/mcp/easydocs_mcp.py` | **Create.** The entire server: env → client → spec → `from_openapi` → `run()`. Exposes `build(spec, client)` so the test builds the same server without env or network. | +| `packages/mcp/test_easydocs_mcp.py` | **Create.** Two facts: exact tool-name set; every tool is a GET. Reads the committed snapshot. | +| `packages/mcp/README.md` | **Create.** The one place setup instructions live. | +| `packages/mcp/LICENSE` | **Create.** MIT. | +| `.github/workflows/ci.yml` | **Modify** `docs-build`: install fastmcp+pytest, run `pytest packages/mcp`. | +| `README.md` | **Modify** §API (one bullet) and §License (table row + sentence). | +| `CONTRIBUTING.md` | **Modify** licensing bullets (lines 43–45). | +| `docs/architecture-decisions.md` | **Modify** ADR-13 lines 217–218. | +| `docs-site/docs/automation-recipes.md` | **Modify** `## Other useful calls`: one paragraph + link. | +| `CHANGELOG.md` | **Modify** `[Unreleased] / Added`. | +| `docs/superpowers/specs/2026-08-26-mcp-server-design.md` | **Modify** the naming section: record that the fork is resolved. | + +Verified facts the code relies on (from the scratch run, keep for reference): +- `from fastmcp.server.providers.openapi import RouteMap, MCPType` +- `RouteMap(*, methods='*'|list[str], pattern='.*'|str, tags=set(), mcp_type, mcp_tags=set())` +- `FastMCP.from_openapi(openapi_spec, client=None, name=..., route_maps=None, route_map_fn=None, mcp_component_fn=None, mcp_names=None, tags=None, validate_output=True, **settings)` +- `mcp_component_fn(route, component)`: `route.method` (e.g. `"GET"`), `route.path` (e.g. `"/api/v1/documents/{id}"`); setting `component.name` sticks. +- `await mcp.list_tools()` → `list[OpenAPITool]`, each with `.name` and `._route` (`.method`, `.path`). +- Our allowlist matched exactly 17 routes against `docs-site/docs/api/openapi/v1.json`. + +--- + +### Task 1: The server script + +**Files:** +- Create: `packages/mcp/easydocs_mcp.py` +- Create: `packages/mcp/test_easydocs_mcp.py` + +- [ ] **Step 1: Write the failing test** + +```python +# packages/mcp/test_easydocs_mcp.py +"""Builds the MCP server from the committed OpenAPI snapshot and pins its tool surface. + +The snapshot is kept identical to the served /openapi/v1.json by the C# test +Openapi_snapshot_in_docs_site_matches_the_served_document, so passing here means passing against +the real API document. Both facts here are the read-only guarantee as executable checks. +""" +import asyncio +import json +from pathlib import Path + +import httpx + +import easydocs_mcp + +SNAPSHOT = Path(__file__).resolve().parents[2] / "docs-site" / "docs" / "api" / "openapi" / "v1.json" + +EXPECTED = { + "list_documents", "get_document", "list_versions", "get_version", "compare_versions", + "list_audit_events", "list_members", "list_publications", "preview_merge", "list_copies", + "list_push_requests", "list_my_approvals", "list_version_approvals", "list_folders", + "whoami", "get_org", "list_my_orgs", +} + + +def _tools(): + spec = json.loads(SNAPSHOT.read_text()) + server = easydocs_mcp.build(spec, httpx.AsyncClient(base_url="http://example.invalid")) + return asyncio.run(server.list_tools()) + + +def test_tool_set_is_exactly_the_read_surface(): + names = {t.name for t in _tools()} + assert names == EXPECTED, f"unexpected: {names - EXPECTED}; missing: {EXPECTED - names}" + + +def test_every_tool_is_a_get(): + # Read-only is a property of the tool set. A future POST re-tagged "Documents" must not slip + # past the method filter unnoticed. + not_get = {t.name: t._route.method for t in _tools() if t._route.method != "GET"} + assert not_get == {}, not_get +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run from the repo root: +```bash +uv run --with "fastmcp>=3.4,<4" --with pytest pytest packages/mcp -q +``` +Expected: `ModuleNotFoundError: No module named 'easydocs_mcp'` (collection error). + +- [ ] **Step 3: Write the server** + +```python +# packages/mcp/easydocs_mcp.py +# /// script +# requires-python = ">=3.10" +# dependencies = ["fastmcp>=3.4,<4"] +# /// +"""easydocs as MCP tools — the read side of the API, generated from its own OpenAPI document. + +Runs on YOUR machine as YOU: + EASYDOCS_URL=https://docs.example.com EASYDOCS_TOKEN=ed_... uv run easydocs_mcp.py + +Read-only is a property of this tool set, not of the token: an ed_ token carries its owner's full +document role, so keep it as private as a password. See README.md. +""" +import os +import sys + +import httpx +from fastmcp import FastMCP +from fastmcp.server.providers.openapi import MCPType, RouteMap + +# The seventeen tools. Keys are "METHOD path" exactly as they appear in the OpenAPI document. The +# document has no operationIds (minimal APIs emit none without .WithName), so names live here — the +# table in the spec and this dict are the same thing. +NAMES = { + "GET /api/v1/documents": "list_documents", + "GET /api/v1/documents/{id}": "get_document", + "GET /api/v1/documents/{id}/versions": "list_versions", + "GET /api/v1/versions/{vid}": "get_version", + "GET /api/v1/documents/{id}/compare": "compare_versions", + "GET /api/v1/documents/{id}/audit": "list_audit_events", + "GET /api/v1/documents/{id}/members": "list_members", + "GET /api/v1/documents/{id}/publications": "list_publications", + "GET /api/v1/documents/{id}/merges/preview": "preview_merge", + "GET /api/v1/documents/{id}/copies": "list_copies", + "GET /api/v1/documents/{id}/push-requests": "list_push_requests", + "GET /api/v1/approvals": "list_my_approvals", + "GET /api/v1/versions/{vid}/approvals": "list_version_approvals", + "GET /api/v1/folders": "list_folders", + "GET /api/v1/me": "whoami", + "GET /api/v1/org": "get_org", + "GET /api/v1/orgs": "list_my_orgs", +} + +READ_TAGS = ["Documents", "Folders", "Audit", "Members", "Approvals", "Publishing", "Merging", "Copies"] + +# Evaluated in order, first match wins. The trailing catch-all is the point: a new endpoint is +# excluded until someone adds it here on purpose. +ROUTE_MAPS = [ + # Binary docx/pdf — useless to a model and large. Carries the Documents tag, so it must go first. + RouteMap(methods=["GET"], pattern=r"^/api/v1/versions/\{vid\}/download$", mcp_type=MCPType.EXCLUDE), + *[RouteMap(methods=["GET"], tags={t}, mcp_type=MCPType.TOOL) for t in READ_TAGS], + RouteMap(methods=["GET"], pattern=r"^/api/v1/(me|org|orgs)$", mcp_type=MCPType.TOOL), + # Everything else: all writes, Auth/MFA/SSO/Tokens, Sharing, Editing, the SSE stream, WOPI, + # WebDAV, /s/, /health, and GET /api/v1/org/members. + RouteMap(pattern=r".*", mcp_type=MCPType.EXCLUDE), +] + + +def _rename(route, component): + key = f"{route.method} {route.path}" + # A route that passed the allowlist but has no name here is a mistake in one of the two lists; + # fail loudly rather than expose an auto-slugged name. + if key not in NAMES: + raise KeyError(f"{key} passed the allowlist but has no entry in NAMES") + component.name = NAMES[key] + + +def build(spec: dict, client: httpx.AsyncClient) -> FastMCP: + return FastMCP.from_openapi( + openapi_spec=spec, client=client, name="easydocs", + route_maps=ROUTE_MAPS, mcp_component_fn=_rename, + ) + + +def main() -> None: + # os.environ[...] raises KeyError naming the variable — that is the fail-fast we want. + base = os.environ["EASYDOCS_URL"].rstrip("/") # trailing slash: the perennial "set your URL" ticket + token = os.environ["EASYDOCS_TOKEN"] + headers = {"Authorization": f"Bearer {token}"} + + spec = httpx.get(f"{base}/openapi/v1.json", headers=headers, timeout=30).raise_for_status().json() + # 60 s, not httpx's 5 s: compare_versions?format=html renders a redline on demand. + client = httpx.AsyncClient(base_url=base, headers=headers, timeout=60) + + # stdout is the protocol stream from here on; anything we say goes to stderr. + print(f"easydocs MCP: {len(NAMES)} read-only tools against {base}", file=sys.stderr) + build(spec, client).run() + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +uv run --with "fastmcp>=3.4,<4" --with pytest pytest packages/mcp -q +``` +Expected: `2 passed`. + +- [ ] **Step 5: Prove the fail-fast and the script form** + +```bash +uv run packages/mcp/easydocs_mcp.py; echo "exit=$?" +``` +Expected: a `KeyError: 'EASYDOCS_URL'` traceback on stderr and `exit=1`. (Optional, if a local +stack is up: `EASYDOCS_URL=http://localhost:8080 EASYDOCS_TOKEN=ed_… uv run packages/mcp/easydocs_mcp.py` +prints `easydocs MCP: 17 read-only tools against http://localhost:8080` to stderr and waits on stdin — Ctrl-C.) + +- [ ] **Step 6: Commit** + +```bash +git add packages/mcp/easydocs_mcp.py packages/mcp/test_easydocs_mcp.py +git commit -s -m "feat(mcp): read-only MCP server generated from the OpenAPI document" +``` + +--- + +### Task 2: Licence and README for the package + +**Files:** +- Create: `packages/mcp/LICENSE` +- Create: `packages/mcp/README.md` + +- [ ] **Step 1: Write the MIT licence** + +```text +MIT License + +Copyright (c) 2026 easydocs contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +- [ ] **Step 2: Write the README** + +````markdown +# easydocs MCP server + +Use your easydocs install from Claude Code, Cursor, Codex, Gemini CLI, Claude Desktop — any +[MCP](https://modelcontextprotocol.io) client. **Read-only:** browse documents, history, redlines, +audit trails and approvals. It cannot upload, publish, merge, revert or delete. + +One Python file. It fetches your install's own `/openapi/v1.json` at start-up and turns the read +endpoints into tools, so it never drifts from the API it talks to. Runs on **your** machine as +**you**. MIT-licensed (the rest of easydocs is AGPL-3.0). + +## Setup + +You need [`uv`](https://docs.astral.sh/uv/) and a personal access token: in easydocs, **Settings → +API tokens → New token**. Copy it once; it is shown once. + +**Claude Code** + +```bash +claude mcp add easydocs \ + -e EASYDOCS_URL=https://docs.example.com \ + -e EASYDOCS_TOKEN=ed_... \ + -- uv run https://raw.githubusercontent.com/AptsNY/easydocs/main/packages/mcp/easydocs_mcp.py +``` + +**Cursor** (`~/.cursor/mcp.json`), **Claude Desktop** (`claude_desktop_config.json`), **Gemini CLI** +(`~/.gemini/settings.json`) — all take the same command / args / env: + +```json +{ + "mcpServers": { + "easydocs": { + "command": "uv", + "args": ["run", "https://raw.githubusercontent.com/AptsNY/easydocs/main/packages/mcp/easydocs_mcp.py"], + "env": { + "EASYDOCS_URL": "https://docs.example.com", + "EASYDOCS_TOKEN": "ed_..." + } + } + } +} +``` + +Codex's `~/.codex/config.toml` is TOML, same three fields: + +```toml +[mcp_servers.easydocs] +command = "uv" +args = ["run", "https://raw.githubusercontent.com/AptsNY/easydocs/main/packages/mcp/easydocs_mcp.py"] +env = { EASYDOCS_URL = "https://docs.example.com", EASYDOCS_TOKEN = "ed_..." } +``` + +Prefer a pinned copy? Clone the repo and point `args` at `packages/mcp/easydocs_mcp.py` on disk. + +Then ask: *"What changed in the lease agreement between 0.0.4 and 0.1.0?"* or *"Which of my +documents have approvals waiting on me?"* + +## Tools + +| Tool | What it answers | +|---|---| +| `list_documents` | documents you can see — by folder, name/content search (`q`), sort, paging | +| `get_document` | one document's name, folder, org | +| `list_versions` | the history: numbers, authors, branches, change summaries | +| `get_version` | one version's details | +| `compare_versions` | redline between two versions — counts, or the HTML with `format=html` | +| `list_audit_events` | who did what, when, on a document | +| `list_members` | who has which role on a document | +| `list_publications` | published versions and their PDFs | +| `preview_merge` | the three-way review of a pending branch merge | +| `list_copies`, `list_push_requests` | client copies and their push-backs | +| `list_my_approvals`, `list_version_approvals` | approvals asked of you; approvals on a version | +| `list_folders` | the folder tree | +| `whoami`, `get_org`, `list_my_orgs` | who you are, which organization this token is bound to | + +Every call reaches the API as you: a document you are not a member of is a 403 or 404, exactly as +from `curl`. + +## The one thing to know about tokens + +**Read-only is a property of this tool set, not of the token.** An `ed_` token carries the full +document role of the person who minted it; nothing stops the *same token* being used with `curl` to +publish or delete. Keep it as private as a password, set it only through the environment (never a +command-line flag — those show up in `ps`), and revoke it under **Settings → API tokens** if it leaks. + +## Troubleshooting + +- **`KeyError: 'EASYDOCS_URL'`** (or `_TOKEN`) — the env var is missing from the client config. +- **`401` at start-up** — the token is wrong, revoked or expired. +- **A tool is missing** — your install is older than this script and lacks that endpoint. Nothing else + breaks; upgrade the install to get the tool. +- **Never log to stdout** if you modify the script: stdout *is* the protocol stream. Use stderr. +- Client-side logs: Claude Desktop writes `~/Library/Logs/Claude/mcp-server-easydocs.log`; Claude + Code: `claude mcp list` / `/mcp`. +```` + +- [ ] **Step 3: Commit** + +```bash +git add packages/mcp/LICENSE packages/mcp/README.md +git commit -s -m "docs(mcp): README and MIT licence for packages/mcp" +``` + +--- + +### Task 3: CI + +**Files:** +- Modify: `.github/workflows/ci.yml` (the `docs-build` job, after `Install MkDocs Material`) + +- [ ] **Step 1: Add the test step** + +Insert after the `Install MkDocs Material` step (currently lines 177–178) and before `Build the docs site`: + +```yaml + # packages/mcp is a Python client of the API, and this job already has Python. Its test pins + # the read-only tool surface against the committed OpenAPI snapshot (which the .NET job pins + # against the served document), so a write endpoint cannot leak into the MCP tool set unnoticed. + - name: Test the MCP server package + run: | + pip install "fastmcp>=3.4,<4" pytest + pytest packages/mcp -q +``` + +- [ ] **Step 2: Validate the YAML locally** + +```bash +python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ci.yml')); print('ok')" 2>/dev/null \ + || uv run --with pyyaml python -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml')); print('ok')" +``` +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -s -m "ci: run the MCP package tests in docs-build" +``` + +--- + +### Task 4: Flip the "packages/ does not exist yet" wording everywhere + +**Files:** +- Modify: `README.md:141-152` (§API) and `README.md:199-213` (§License) +- Modify: `CONTRIBUTING.md:43-45` +- Modify: `docs/architecture-decisions.md:211-223` (ADR-13) +- Modify: `docs-site/docs/automation-recipes.md:252` (`## Other useful calls`) +- Modify: `CHANGELOG.md` under `## [Unreleased]` / `### Added` + +- [ ] **Step 1: README §API — add one bullet** after the `Live updates` bullet (line 150): + +```markdown +- **MCP:** a read-only [MCP server](packages/mcp/) for Claude Code, Cursor and other AI coding agents, + generated from the OpenAPI document — install with one command, runs on your machine as you. +``` + +- [ ] **Step 2: README §License — replace the table row and the sentence** + +Replace: +```markdown +| `packages/*` — future API client SDKs | **MIT**, when written. The directory does not exist yet. | +``` +with: +```markdown +| `packages/*` — API clients | **MIT.** Today: [`packages/mcp`](packages/mcp/), the MCP server. | +``` +Replace the opening line `**Everything in this repository today is AGPL-3.0** ([LICENSE](LICENSE)) — server, SPA, tests, deploy +files, docs.` with: +```markdown +**Everything in this repository is AGPL-3.0** ([LICENSE](LICENSE)) — server, SPA, tests, deploy +files, docs — except `packages/*`, which is MIT. +``` +Replace `| Everything in this repo | **AGPL-3.0** — the whole repository right now |` with +`| Everything outside `packages/*` | **AGPL-3.0** |`. +Replace the sentence `so future SDKs will live +under `packages/*` with their own MIT `LICENSE`. **Until that directory exists, assume AGPL-3.0 for +anything you take from here.**` with: +```markdown +so clients live +under `packages/*` with their own MIT `LICENSE`. **Anything outside that directory is AGPL-3.0.** +``` + +- [ ] **Step 3: CONTRIBUTING.md — replace lines 43–45** + +```markdown +- **Everything in this repository is AGPL-3.0, except `packages/*`.** +- `packages/*` holds API clients and is **MIT** — today that is `packages/mcp`, the MCP server. Each + package carries its own `LICENSE`. +``` + +- [ ] **Step 4: ADR-13 — update the decision paragraph** + +Replace the sentence spanning lines 217–218 (`... a directory boundary you can point at. Until that +directory exists, nothing here is MIT.`) with: +```markdown +`packages/*` with their own MIT license — a directory boundary you can point at. The first occupant +is `packages/mcp`, the MCP server (2026-08). +``` +Update the heading `## ADR-13: AGPL server, MIT SDKs (when they exist)` to `## ADR-13: AGPL server, MIT clients under packages/*`. +Update the Consequences sentence `and will be MIT-smooth once SDKs exist.` to `and is MIT where the client code lives.` + +- [ ] **Step 5: automation-recipes.md — one paragraph under `## Other useful calls`** + +Insert immediately after the `## Other useful calls` heading (line 252), before the code block: + +```markdown +Prefer talking to an AI agent instead of `curl`? The [MCP server](https://github.com/AptsNY/easydocs/tree/main/packages/mcp) +exposes the read side of this API — every `GET` below — as tools for Claude Code, Cursor and friends, +authenticated with the same `ed_` token. +``` + +- [ ] **Step 6: CHANGELOG.md — new entry at the top of `[Unreleased] / Added`** + +```markdown +- **easydocs can be used from AI coding agents.** `packages/mcp/easydocs_mcp.py` is a read-only + [MCP](https://modelcontextprotocol.io) server generated from the install's own `/openapi/v1.json`: + seventeen tools covering documents, history, redlines, audit trails, approvals and folders, for + Claude Code, Cursor, Codex, Gemini CLI and Claude Desktop. It runs on the user's machine with their + own `ed_` token, so every call is authorized exactly as their `curl` would be. Nothing on the + server changed. `packages/*` is now real and MIT-licensed, as the licence section always said it + would be. +``` + +- [ ] **Step 7: Build the docs site strictly (mkdocs is what CI runs)** + +```bash +uv run --with mkdocs-material --with mkdocs-swagger-ui-tag --directory docs-site mkdocs build --strict +``` +Expected: exits 0, no `WARNING` lines. (The recipes link is absolute to GitHub, so it is not a nav-validated internal link.) + +- [ ] **Step 8: Commit** + +```bash +git add README.md CONTRIBUTING.md docs/architecture-decisions.md docs-site/docs/automation-recipes.md CHANGELOG.md +git commit -s -m "docs: packages/mcp exists — flip the licence wording and link the MCP server" +``` + +--- + +### Task 5: Record the resolved fork in the spec and finish + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-26-mcp-server-design.md` (section "Tool names: rename in the adapter…") + +- [ ] **Step 1: Replace the "Fork, decided by the plan's first task" paragraph** with: + +```markdown +Resolved 2026-08-26 before implementation: a scratch run against `fastmcp` 3.4.7 confirmed +`mcp_component_fn` receives `route.method` / `route.path` and a settable `component.name` with +`operationId` null, and the allowlist matched exactly seventeen routes. No `src/` change was needed. +``` + +- [ ] **Step 2: Run the whole verification once more** + +```bash +uv run --with "fastmcp>=3.4,<4" --with pytest pytest packages/mcp -q # 2 passed +git status --short # only the spec edit +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/superpowers/specs/2026-08-26-mcp-server-design.md +git commit -s -m "docs(spec): record that the MCP naming fork resolved to zero src/ changes" +``` + +- [ ] **Step 4: Hand off** — use superpowers:finishing-a-development-branch: push `feat/mcp-server`, open a PR against `main` titled `feat: read-only MCP server generated from the OpenAPI document`, body summarising the spec and linking it; end the body with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. + +--- + +## Not in this plan (by design — see spec "Follow-ups") + +`GET /versions/{vid}/text`; write tools behind an opt-in; a hosted `/mcp` with OAuth; a `pyproject.toml`/PyPI publish; a docs-site page beyond the recipes link. diff --git a/docs/superpowers/specs/2026-08-26-mcp-server-design.md b/docs/superpowers/specs/2026-08-26-mcp-server-design.md new file mode 100644 index 0000000..4186700 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-mcp-server-design.md @@ -0,0 +1,217 @@ +# An MCP server for easydocs — design + +**Date:** 2026-08-26 +**Status:** Approved, ready for implementation planning +**Touches:** new `packages/mcp/` (Python, MIT); `.github/workflows/ci.yml` (`docs-build` job); +`README.md` (API + licence sections); `CONTRIBUTING.md`; `docs/architecture-decisions.md` (ADR-13); +`docs-site/docs/automation-recipes.md` (one link); `CHANGELOG.md`. **No change to `src/`** unless the +naming fork below forces one. + +## Problem + +Coding agents (Claude Code, Cursor, Codex, Gemini CLI) speak the Model Context Protocol. easydocs +speaks REST. Someone who wants an agent to answer "what changed in the lease between 0.0.4 and +0.1.0?" today has to hand it `curl` and the OpenAPI document and hope. The API already does +everything the UI does (ADR-10), so the missing piece is not capability — it is a protocol adapter. + +## Scope + +**In:** an MCP server that exposes the *read* side of the easydocs API as tools; runs on the +person's own machine, authenticated as them with their own `ed_` token; installable with one +command from any HTTP-or-stdio MCP client. + +**Out:** write tools (upload, publish, merge, revert, delete — deliberate, see Decisions); a hosted +multi-tenant MCP endpoint; OAuth / dynamic client registration (what claude.ai web connectors +need); a plain-text "read this document" tool (the API has no such endpoint — a candidate follow-up); +binary downloads. + +## Decisions + +### Generate the tools from `/openapi/v1.json` with FastMCP; do not hand-write them + +[FastMCP](https://gofastmcp.com) (Python, 3.x) has `FastMCP.from_openapi(spec, client)`: it turns each +OpenAPI operation into an MCP tool, forwards calls to the API over an `httpx` client, and preserves +the operation's parameters, schemas and descriptions. Our OpenAPI document is generated from the +running code and cannot go stale (ADR-10), so the adapter cannot drift from the API either. + +Considered and rejected for this phase: + +- **In-process C# server** (`ModelContextProtocol.AspNetCore`, `app.MapMcp("/mcp")`, hand-written + `[McpServerTool]` methods). Curated tools, no second process, token passthrough for free — but a + new runtime dependency (GOVERNANCE: issue first), a new authenticated surface in the shipped + image, and every tool is code to write and test. It remains the named upgrade path if agents turn + out to need curated tools (FastMCP's own docs say auto-generated servers are a bootstrapping + surface, not the end state). +- **Hosted FastMCP sidecar** (a fourth container). FastMCP's `httpx` client carries one fixed + `Authorization` header, so a shared instance either acts as one user for everyone or needs + per-request header forwarding. Neither is acceptable for a product whose authorization is + per-document, per-person. + +### Run locally, per person, over stdio + +Each user runs the server on their own machine with two environment variables: + +``` +EASYDOCS_URL = https://docs.example.com # their install +EASYDOCS_TOKEN = ed_... # their own personal access token +``` + +The server fetches `${EASYDOCS_URL}/openapi/v1.json` at start-up, builds the tools, and speaks +stdio to the client that launched it. Every call reaches the API as that person, so +`DocumentAuthorization` applies unchanged: a document they are not a member of is a 403 or 404 +exactly as it would be from `curl`. No new authentication code anywhere. + +Consequence stated plainly: **read-only is a property of the tool set, not of the token.** `ed_` +tokens have a `Scopes` column but the handler does not enforce it — a token used by this server has +the full role of its owner. The allowlist below is what keeps an agent from publishing or deleting; +the docs say so. + +### Allowlist by tag and method; deny everything else + +FastMCP `route_maps` are evaluated in order, first match wins. Ours: + +| # | Rule | Effect | +|---|---|---| +| 1 | `GET /api/v1/versions/{vid}/download` → **EXCLUDE** | binary; useless to a model, large. Must precede rule 2: it carries the `Documents` tag | +| 2 | `GET` + tag ∈ {Documents, Folders, Audit, Members, Approvals, Publishing, Merging, Copies} → **TOOL** | the read surface | +| 3 | `GET /api/v1/me`, `/api/v1/org`, `/api/v1/orgs` → **TOOL** | "who am I, which org" | +| 4 | everything else → **EXCLUDE** | all writes; Auth, MFA, SSO, Tokens, Sharing, Editing, Events (the SSE stream — tagged `Events`, so it lands here), WOPI, WebDAV, `/s/`, `/health`, and `GET /api/v1/org/members` (org roster — not needed to work on documents) | + +Excluding by a trailing catch-all rather than listing writes means a **new endpoint is excluded by +default** until someone adds it here on purpose. The resulting tool set, with the names given in the +next decision: + +| Tool | Endpoint | +|---|---| +| `list_documents` | `GET /api/v1/documents` (folder, `q` name+content search, sort, paging) | +| `get_document` | `GET /api/v1/documents/{id}` | +| `list_versions` | `GET /api/v1/documents/{id}/versions` | +| `get_version` | `GET /api/v1/versions/{vid}` | +| `compare_versions` | `GET /api/v1/documents/{id}/compare?from&to&format` | +| `list_audit_events` | `GET /api/v1/documents/{id}/audit` | +| `list_members` | `GET /api/v1/documents/{id}/members` | +| `list_publications` | `GET /api/v1/documents/{id}/publications` | +| `preview_merge` | `GET /api/v1/documents/{id}/merges/preview` | +| `list_copies` | `GET /api/v1/documents/{id}/copies` | +| `list_push_requests` | `GET /api/v1/documents/{id}/push-requests` | +| `list_my_approvals` | `GET /api/v1/approvals` | +| `list_version_approvals` | `GET /api/v1/versions/{vid}/approvals` | +| `list_folders` | `GET /api/v1/folders` | +| `whoami` | `GET /api/v1/me` | +| `get_org` | `GET /api/v1/org` | +| `list_my_orgs` | `GET /api/v1/orgs` | + +Seventeen tools. `compare_versions` with `format=html` returns the redline HTML, which is the one +"document content" a model can read today. + +### Tool names: rename in the adapter; touch `src/` only if FastMCP cannot + +The served document has **no `operationId` on any operation** — minimal APIs only emit one when the +endpoint has a name. Left alone, FastMCP names tools by a slug of method + path +(`get_api_v1_documents_id_versions`), which is exactly what the MCP naming guidance warns against. + +The names in the table live in **one dict in `easydocs_mcp.py`**, applied through FastMCP's own +renaming hook (`mcp_names`, or `mcp_component_fn` setting `component.name`, whichever the pinned +3.x release supports without an `operationId`). Zero `src/` diff, no snapshot regeneration, and the +table and the code are the same thing. + +Resolved 2026-08-26 before implementation: a scratch run against `fastmcp` 3.4.7 confirmed +`mcp_component_fn` receives `route.method` / `route.path` and a settable `component.name` with +`operationId` null, and the allowlist matched exactly seventeen routes. No `src/` change was needed. +(Had it been, the fallback was `.WithName("")` on the seventeen endpoints — additive +OpenAPI metadata, no GOVERNANCE issue.) + +`snake_case` either way: FastMCP slugifies names, and the guidance is letters, digits, underscores. + +### Package and install + +``` +packages/mcp/ + easydocs_mcp.py # the whole server; PEP 723 inline metadata declares fastmcp>=3.4,<4 + test_easydocs_mcp.py # builds the server from the committed snapshot; asserts the tool set + README.md # per-client setup + LICENSE # MIT +``` + +One script, no `pyproject.toml`, no publish. PEP 723 (`# /// script` … `dependencies = [...]`) lets +`uv` resolve the dependency from the file itself, so the install line is: + +```bash +claude mcp add easydocs \ + -e EASYDOCS_URL=https://docs.example.com -e EASYDOCS_TOKEN=ed_... \ + -- uv run https://raw.githubusercontent.com/AptsNY/easydocs/main/packages/mcp/easydocs_mcp.py +``` + +Cursor, Codex and Gemini CLI take the same `command` / `args` / `env` triple in their JSON config; +the README shows each. Claude Desktop is the same recipe (it is a stdio client). A `pyproject.toml` +appears the day this is published to PyPI, not before. + +`packages/mcp` is **MIT**. The README's licence section, CONTRIBUTING, and ADR-13 in +`docs/architecture-decisions.md` all reserve `packages/*` for MIT client code and say the directory +does not exist yet; all three flip to say it now does and what is in it. Everything else in the +repository stays AGPL-3.0. + +The FastMCP API this design leans on (`from_openapi`, `route_maps`, `RouteMap(methods, pattern, +tags, mcp_type)`, `MCPType`, the renaming hook) was read from the 3.x docs, not exercised. The plan's +first task pins `fastmcp` to the current 3.x release and confirms each in a scratch script; it also +confirms that a route missing from an older install's spec yields a missing *tool*, not a failed +server (see Testing). + +### The client, and its two non-negotiable lines + +- `httpx.AsyncClient(base_url=EASYDOCS_URL.rstrip("/"), headers={"Authorization": f"Bearer {token}"}, timeout=60)`. + `rstrip("/")` because a trailing slash on "your install URL" is the perennial support ticket. + `timeout=60` because `compare_versions?format=html` renders a redline on demand and httpx's 5 s + default would make the most useful tool the flakiest. + +### Failure behaviour + +- Missing `EASYDOCS_URL` / `EASYDOCS_TOKEN`: `os.environ[...]` raises with the variable named and the + process exits non-zero before the transport starts. That is the fail-fast rule (ADR-11) for free; + it needs no wrapper and no test. +- Never print to stdout after the transport starts — a stdio server that logs to stdout corrupts the + protocol stream; anything the script says goes to stderr. +- Spec fetch fails (wrong URL, install down): the `httpx` error is reported on stderr and the + process exits. An API error during a tool call (403, 404, 400) is surfaced to the model as the + tool's error result carrying the API's own `problem+json` `detail`, which is already written for + humans. +- Tokens are read from the environment only. Never a command-line flag (visible in `ps`), never a + config file this package owns. + +## Testing + +**One test file, `packages/mcp/test_easydocs_mcp.py`**, two facts: + +1. Load `docs-site/docs/api/openapi/v1.json` (the committed snapshot, kept honest by the existing + `Openapi_snapshot_in_docs_site_matches_the_served_document`), build the server with a dummy + client, and assert the tool name set is **exactly** the seventeen above. A write endpoint leaking + into the allowlist, a name changing, or a new read route arriving un-named all fail here with the + offending name printed — which is why there is no separate C# guard. +2. Assert every generated tool's underlying route method is `GET`. This makes "read-only is a + property of the tool set" true by construction rather than by inspection: a future `POST` + re-tagged `Documents` cannot slip through rule 2's method filter unnoticed. + +**CI**: appended to the existing `docs-build` job (it already sets up Python): `pip install +"fastmcp>=3.4,<4" pytest && pytest packages/mcp`. No new job, no new action. + +Not tested here: a live call through a real MCP client — this package is a client of the shipped +container, and its contract with the container is the OpenAPI snapshot, already pinned. Version +skew is by design tolerated: a user's older install whose spec lacks a route simply lacks that tool. + +## Documentation + +- `packages/mcp/README.md`: what it is, the seventeen tools, per-client setup (Claude Code, Cursor, + Codex, Gemini CLI, Claude Desktop), the read-only / token-scope caveat, how to mint an `ed_` token + (Settings → API tokens). This is the one place the instructions live. +- `docs-site/docs/automation-recipes.md`: one paragraph and a link to that README. A dedicated + docs-site page is written the first time the README proves insufficient for a user, not before. +- `README.md`: one paragraph under API; the licence section updated. +- `CHANGELOG.md` under `[Unreleased] / Added`. + +## Follow-ups this design deliberately leaves open + +- A `GET /api/v1/versions/{vid}/text` endpoint (plain text via the existing `DocxText.Extract`) — + the single most useful read an agent lacks. Public-API change, so it goes through an issue. +- Write tools, behind an explicit opt-in (`EASYDOCS_MCP_ALLOW_WRITES=1`) once the read surface has + been used in anger. +- A hosted `/mcp` inside the app with OAuth, for claude.ai connectors — the in-process C# route above. diff --git a/packages/mcp/LICENSE b/packages/mcp/LICENSE new file mode 100644 index 0000000..4fa9e4a --- /dev/null +++ b/packages/mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 easydocs contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000..047f5f3 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,93 @@ +# easydocs MCP server + +Use your easydocs install from Claude Code, Cursor, Codex, Gemini CLI, Claude Desktop — any +[MCP](https://modelcontextprotocol.io) client. **Read-only:** browse documents, history, redlines, +audit trails and approvals. It cannot upload, publish, merge, revert or delete. + +One Python file. It fetches your install's own `/openapi/v1.json` at start-up and turns the read +endpoints into tools, so it never drifts from the API it talks to. Runs on **your** machine as +**you**. MIT-licensed (the rest of easydocs is AGPL-3.0). + +## Setup + +You need [`uv`](https://docs.astral.sh/uv/) and a personal access token: in easydocs, **Settings → +API tokens → New token**. Copy it once; it is shown once. + +**Claude Code** + +```bash +claude mcp add easydocs \ + -e EASYDOCS_URL=https://docs.example.com \ + -e EASYDOCS_TOKEN=ed_... \ + -- uv run https://raw.githubusercontent.com/AptsNY/easydocs/main/packages/mcp/easydocs_mcp.py +``` + +**Cursor** (`~/.cursor/mcp.json`), **Claude Desktop** (`claude_desktop_config.json`), **Gemini CLI** +(`~/.gemini/settings.json`) — all take the same command / args / env: + +```json +{ + "mcpServers": { + "easydocs": { + "command": "uv", + "args": ["run", "https://raw.githubusercontent.com/AptsNY/easydocs/main/packages/mcp/easydocs_mcp.py"], + "env": { + "EASYDOCS_URL": "https://docs.example.com", + "EASYDOCS_TOKEN": "ed_..." + } + } + } +} +``` + +Codex's `~/.codex/config.toml` is TOML, same three fields: + +```toml +[mcp_servers.easydocs] +command = "uv" +args = ["run", "https://raw.githubusercontent.com/AptsNY/easydocs/main/packages/mcp/easydocs_mcp.py"] +env = { EASYDOCS_URL = "https://docs.example.com", EASYDOCS_TOKEN = "ed_..." } +``` + +Prefer a pinned copy? Clone the repo and point `args` at `packages/mcp/easydocs_mcp.py` on disk. + +Then ask: *"What changed in the lease agreement between 0.0.4 and 0.1.0?"* or *"Which of my +documents have approvals waiting on me?"* + +## Tools + +| Tool | What it answers | +|---|---| +| `list_documents` | documents you can see — by folder, name/content search (`q`), sort, paging | +| `get_document` | one document's name, folder, org | +| `list_versions` | the history: numbers, authors, branches, change summaries | +| `get_version` | one version's details | +| `compare_versions` | redline between two versions — counts, or the HTML with `format=html` | +| `list_audit_events` | who did what, when, on a document | +| `list_members` | who has which role on a document | +| `list_publications` | published versions and their PDFs | +| `preview_merge` | the three-way review of a pending branch merge | +| `list_copies`, `list_push_requests` | client copies and their push-backs | +| `list_my_approvals`, `list_version_approvals` | approvals asked of you; approvals on a version | +| `list_folders` | the folder tree | +| `whoami`, `get_org`, `list_my_orgs` | who you are, which organization this token is bound to | + +Every call reaches the API as you: a document you are not a member of is a 403 or 404, exactly as +from `curl`. + +## The one thing to know about tokens + +**Read-only is a property of this tool set, not of the token.** An `ed_` token carries the full +document role of the person who minted it; nothing stops the *same token* being used with `curl` to +publish or delete. Keep it as private as a password, set it only through the environment (never a +command-line flag — those show up in `ps`), and revoke it under **Settings → API tokens** if it leaks. + +## Troubleshooting + +- **`KeyError: 'EASYDOCS_URL'`** (or `_TOKEN`) — the env var is missing from the client config. +- **`401` at start-up** — the token is wrong, revoked or expired. +- **A tool is missing** — your install is older than this script and lacks that endpoint. Nothing else + breaks; upgrade the install to get the tool. +- **Never log to stdout** if you modify the script: stdout *is* the protocol stream. Use stderr. +- Client-side logs: Claude Desktop writes `~/Library/Logs/Claude/mcp-server-easydocs.log`; Claude + Code: `claude mcp list` / `/mcp`. diff --git a/packages/mcp/easydocs_mcp.py b/packages/mcp/easydocs_mcp.py new file mode 100644 index 0000000..40ec751 --- /dev/null +++ b/packages/mcp/easydocs_mcp.py @@ -0,0 +1,91 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["fastmcp>=3.4,<4"] +# /// +"""easydocs as MCP tools — the read side of the API, generated from its own OpenAPI document. + +Runs on YOUR machine as YOU: + EASYDOCS_URL=https://docs.example.com EASYDOCS_TOKEN=ed_... uv run easydocs_mcp.py + +Read-only is a property of this tool set, not of the token: an ed_ token carries its owner's full +document role, so keep it as private as a password. See README.md. +""" +import os +import sys + +import httpx +from fastmcp import FastMCP +from fastmcp.server.providers.openapi import MCPType, RouteMap + +# The seventeen tools. Keys are "METHOD path" exactly as they appear in the OpenAPI document. The +# document has no operationIds (minimal APIs emit none without .WithName), so names live here — the +# table in the spec and this dict are the same thing. +NAMES = { + "GET /api/v1/documents": "list_documents", + "GET /api/v1/documents/{id}": "get_document", + "GET /api/v1/documents/{id}/versions": "list_versions", + "GET /api/v1/versions/{vid}": "get_version", + "GET /api/v1/documents/{id}/compare": "compare_versions", + "GET /api/v1/documents/{id}/audit": "list_audit_events", + "GET /api/v1/documents/{id}/members": "list_members", + "GET /api/v1/documents/{id}/publications": "list_publications", + "GET /api/v1/documents/{id}/merges/preview": "preview_merge", + "GET /api/v1/documents/{id}/copies": "list_copies", + "GET /api/v1/documents/{id}/push-requests": "list_push_requests", + "GET /api/v1/approvals": "list_my_approvals", + "GET /api/v1/versions/{vid}/approvals": "list_version_approvals", + "GET /api/v1/folders": "list_folders", + "GET /api/v1/me": "whoami", + "GET /api/v1/org": "get_org", + "GET /api/v1/orgs": "list_my_orgs", +} + +READ_TAGS = ["Documents", "Folders", "Audit", "Members", "Approvals", "Publishing", "Merging", "Copies"] + +# Evaluated in order, first match wins. The trailing catch-all is the point: a new endpoint is +# excluded until someone adds it here on purpose. +ROUTE_MAPS = [ + # Binary docx/pdf — useless to a model and large. Carries the Documents tag, so it must go first. + RouteMap(methods=["GET"], pattern=r"^/api/v1/versions/\{vid\}/download$", mcp_type=MCPType.EXCLUDE), + *[RouteMap(methods=["GET"], tags={t}, mcp_type=MCPType.TOOL) for t in READ_TAGS], + RouteMap(methods=["GET"], pattern=r"^/api/v1/(me|org|orgs)$", mcp_type=MCPType.TOOL), + # Everything else: all writes, Auth/MFA/SSO/Tokens, Sharing, Editing, the SSE stream, WOPI, + # WebDAV, /s/, /health, and GET /api/v1/org/members. + RouteMap(pattern=r".*", mcp_type=MCPType.EXCLUDE), +] + + +def _rename(route, component): + key = f"{route.method} {route.path}" + # A route that passed the allowlist but has no name here is a mistake in one of the two lists; + # fail loudly rather than expose an auto-slugged name. (The test's exact-set assertion is the + # real guard — this raise is only the nearer error message.) + if key not in NAMES: + raise KeyError(f"{key} passed the allowlist but has no entry in NAMES") + component.name = NAMES[key] + + +def build(spec: dict, client: httpx.AsyncClient) -> FastMCP: + return FastMCP.from_openapi( + openapi_spec=spec, client=client, name="easydocs", + route_maps=ROUTE_MAPS, mcp_component_fn=_rename, + ) + + +def main() -> None: + # os.environ[...] raises KeyError naming the variable — that is the fail-fast we want. + base = os.environ["EASYDOCS_URL"].rstrip("/") # trailing slash: the perennial "set your URL" ticket + token = os.environ["EASYDOCS_TOKEN"] + headers = {"Authorization": f"Bearer {token}"} + + spec = httpx.get(f"{base}/openapi/v1.json", headers=headers, timeout=30).raise_for_status().json() + # 60 s, not httpx's 5 s: compare_versions?format=html renders a redline on demand. + client = httpx.AsyncClient(base_url=base, headers=headers, timeout=60) + + # stdout is the protocol stream from here on; anything we say goes to stderr. + print(f"easydocs MCP: {len(NAMES)} read-only tools against {base}", file=sys.stderr) + build(spec, client).run() + + +if __name__ == "__main__": + main() diff --git a/packages/mcp/test_easydocs_mcp.py b/packages/mcp/test_easydocs_mcp.py new file mode 100644 index 0000000..c81c68c --- /dev/null +++ b/packages/mcp/test_easydocs_mcp.py @@ -0,0 +1,40 @@ +"""Builds the MCP server from the committed OpenAPI snapshot and pins its tool surface. + +The snapshot is kept identical to the served /openapi/v1.json by the C# test +Openapi_snapshot_in_docs_site_matches_the_served_document, so passing here means passing against +the real API document. Both facts here are the read-only guarantee as executable checks. +""" +import asyncio +import json +from pathlib import Path + +import httpx + +import easydocs_mcp + +SNAPSHOT = Path(__file__).resolve().parents[2] / "docs-site" / "docs" / "api" / "openapi" / "v1.json" + +EXPECTED = { + "list_documents", "get_document", "list_versions", "get_version", "compare_versions", + "list_audit_events", "list_members", "list_publications", "preview_merge", "list_copies", + "list_push_requests", "list_my_approvals", "list_version_approvals", "list_folders", + "whoami", "get_org", "list_my_orgs", +} + + +def _tools(): + spec = json.loads(SNAPSHOT.read_text()) + server = easydocs_mcp.build(spec, httpx.AsyncClient(base_url="http://example.invalid")) + return asyncio.run(server.list_tools()) + + +def test_tool_set_is_exactly_the_read_surface(): + names = {t.name for t in _tools()} + assert names == EXPECTED, f"unexpected: {names - EXPECTED}; missing: {EXPECTED - names}" + + +def test_every_tool_is_a_get(): + # Read-only is a property of the tool set. A future POST re-tagged "Documents" must not slip + # past the method filter unnoticed. + not_get = {t.name: t._route.method for t in _tools() if t._route.method != "GET"} + assert not_get == {}, not_get