diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..43f6109 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,203 @@ +name: Release + +# Builds the package, gates it on the license/boundary check, and — ONLY on +# a version tag or a published GitHub Release — publishes to PyPI and to the +# official MCP registry. A normal push or PR runs the validate job only +# (dry run, no publish), so the workflow itself is testable without shipping. +# +# Auth is OIDC-first and secret-free for the io.github.OpenAdaptAI namespace: +# - PyPI: Trusted Publishing (needs a one-time publisher config on PyPI + +# a GitHub environment named `pypi`; NO repo secret). +# - MCP registry: `mcp-publisher login github-oidc` (NO repo secret). +# Optional token fallbacks are wired and fail LOUD if selected-but-unset; +# see docs/DISTRIBUTION.md. + +on: + push: + tags: ["v*"] + release: + types: [published] + workflow_dispatch: + pull_request: + paths: + - "pyproject.toml" + - "server.json" + - "smithery.yaml" + - "scripts/check_release_artifacts.py" + - ".github/workflows/release.yml" + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + validate: + name: Build + validate (dry run, no publish) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install tooling + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade build "twine>=6.1" "packaging>=24.2" jsonschema + python -m pip install -e ".[dev]" + - name: Version-consistency guard (server.json / pyproject / __version__) + run: python -m pytest tests/test_distribution.py -q + - name: Build sdist + wheel + run: python -m build + - name: License / package-boundary check on built archives + run: python scripts/check_release_artifacts.py dist + - name: twine check + run: twine check dist/* + - name: Validate server.json against the MCP registry schema + run: | + python - <<'PY' + import json, sys, urllib.request + from jsonschema import Draft202012Validator + doc = json.load(open("server.json")) + url = doc["$schema"] + try: + schema = json.load(urllib.request.urlopen(url, timeout=30)) + except Exception as exc: # network hiccup: don't fail the dry run + print(f"WARNING: could not fetch {url}: {exc}; skipping live schema check") + sys.exit(0) + errors = sorted(Draft202012Validator(schema).iter_errors(doc), key=lambda e: list(e.path)) + for e in errors: + print("SCHEMA ERROR:", list(e.path), e.message) + sys.exit(1 if errors else 0) + PY + - name: Upload built distributions + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + if-no-files-found: error + + pypi-publish: + name: Publish to PyPI + needs: validate + if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'release' }} + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # OIDC for PyPI Trusted Publishing + contents: read + steps: + - uses: actions/checkout@v5 + - name: Resolve release version + assert it matches the package + id: ver + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "release" ]; then + tag="${{ github.event.release.tag_name }}" + else + tag="${GITHUB_REF#refs/tags/}" + fi + tag_version="${tag#v}" + pkg_version="$(grep -m1 -E '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/')" + echo "version=${pkg_version}" >> "$GITHUB_OUTPUT" + if [ "${tag_version}" != "${pkg_version}" ]; then + echo "::error::Tag version '${tag_version}' != pyproject version '${pkg_version}'." \ + "Bump pyproject.toml, __init__.py, and server.json together (the" \ + "tests/test_distribution.py guard pins them) and re-tag." >&2 + exit 1 + fi + - name: Download built distributions + uses: actions/download-artifact@v5 + with: + name: dist + path: dist/ + - name: Fail loud if token method selected without a token + if: ${{ vars.PYPI_PUBLISH_METHOD == 'token' }} + env: + TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + if [ -z "${TOKEN}" ]; then + echo "::error::PYPI_PUBLISH_METHOD is 'token' but the repo secret" \ + "PYPI_API_TOKEN is not set. Add it, or unset the variable to use" \ + "OIDC Trusted Publishing (recommended)." >&2 + exit 1 + fi + - name: Publish to PyPI (OIDC Trusted Publishing) + if: ${{ vars.PYPI_PUBLISH_METHOD != 'token' }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + # No password -> the action uses OIDC Trusted Publishing. + print-hash: true + - name: Publish to PyPI (API token fallback) + if: ${{ vars.PYPI_PUBLISH_METHOD == 'token' }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + print-hash: true + + mcp-registry-publish: + name: Publish server.json to the MCP registry + needs: [validate, pypi-publish] + if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'release' }} + runs-on: ubuntu-latest + permissions: + id-token: write # OIDC for `mcp-publisher login github-oidc` + contents: read + steps: + - uses: actions/checkout@v5 + - name: Resolve release version + id: ver + shell: bash + run: | + set -euo pipefail + pkg_version="$(grep -m1 -E '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/')" + echo "version=${pkg_version}" >> "$GITHUB_OUTPUT" + - name: Wait for the PyPI package to be installable + shell: bash + run: | + set -euo pipefail + version="${{ steps.ver.outputs.version }}" + url="https://pypi.org/pypi/openadapt-agent/${version}/json" + for attempt in $(seq 1 20); do + code="$(curl -s -o /dev/null -w '%{http_code}' "$url")" + if [ "$code" = "200" ]; then + echo "openadapt-agent ${version} is live on PyPI." + exit 0 + fi + echo "Attempt ${attempt}: PyPI returned ${code} for ${version}; waiting 15s..." + sleep 15 + done + echo "::error::openadapt-agent ${version} did not appear on PyPI in time." \ + "The MCP registry validates package existence, so it would reject" \ + "this publish. Check the PyPI publish job." >&2 + exit 1 + - name: Install mcp-publisher + shell: bash + run: | + set -euo pipefail + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" + curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${os}_${arch}.tar.gz" \ + | tar xz mcp-publisher + - name: Fail loud if token method selected without a token + if: ${{ vars.MCP_PUBLISH_METHOD == 'token' }} + env: + TOKEN: ${{ secrets.MCP_GITHUB_TOKEN }} + run: | + if [ -z "${TOKEN}" ]; then + echo "::error::MCP_PUBLISH_METHOD is 'token' but the repo secret" \ + "MCP_GITHUB_TOKEN is not set. Add a PAT with read:org + read:user," \ + "or unset the variable to use OIDC (recommended)." >&2 + exit 1 + fi + - name: Authenticate to the MCP registry (OIDC) + if: ${{ vars.MCP_PUBLISH_METHOD != 'token' }} + run: ./mcp-publisher login github-oidc + - name: Authenticate to the MCP registry (PAT fallback) + if: ${{ vars.MCP_PUBLISH_METHOD == 'token' }} + run: ./mcp-publisher login github --token "${{ secrets.MCP_GITHUB_TOKEN }}" + - name: Publish server.json + run: ./mcp-publisher publish diff --git a/README.md b/README.md index bf5950c..d1e6fac 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,15 @@ Design, security model, and what v2.0 deliberately does **not** do: ```bash pip install "openadapt-agent>=2.0.0.dev0" # not yet published; from source: -pip install git+https://github.com/OpenAdaptAI/openadapt-agent@feat/mcp-agent-bridge +pip install git+https://github.com/OpenAdaptAI/openadapt-agent +``` + +Once published, run it without installing — the intended MCP-client entry +point: + +```bash +uvx openadapt-agent serve --bundles /path/to/bundles # read-only +uvx openadapt-agent serve --bundles /path/to/bundles --allow-run ``` Requires Python 3.10–3.12 (inherited from `openadapt-flow`). Installing @@ -121,6 +129,34 @@ around it. - **Timeout ≠ rollback.** A killed run may leave the target mid-workflow; the outcome says so, but nothing undoes partial work. +## Install as an MCP server (registries) + +This package is the **public, official OpenAdapt MCP capability**: inspect +a compiled bundle and, opt-in, run it under governance. A user's compiled +workflow bundle is their **private artifact** — it is supplied at launch +via `--bundles` and is never embedded in the package or any registry +listing. See [`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md) for that +distinction and the publish/submission plan. + +Machine-readable launch manifests live at the repo root: + +- [`server.json`](server.json) — official [MCP registry](https://github.com/modelcontextprotocol/registry) manifest (PyPI package, `uvx` runtime hint, stdio transport). +- [`smithery.yaml`](smithery.yaml) — [Smithery](https://smithery.ai) launch config (stdio `startCommand` with a `bundlesDir` / `allowRun` config schema). +- [`llms.txt`](llms.txt) — a concise, link-first summary for AI assistants. + +Registry-launched installs start **read-only by default**; execution +tools are registered only when the operator adds `--allow-run`. + +Publishing is automated: [`.github/workflows/release.yml`](.github/workflows/release.yml) +builds, runs the license/boundary gate, and — only on a `vX.Y.Z` tag or a +published Release — ships to PyPI (Trusted Publishing, OIDC) and the MCP +registry (`mcp-publisher login github-oidc`), secret-free. It runs a dry +run (no publish) on PRs and manual dispatch. See +[`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md) for the one-time founder +setup. + +Documentation: [docs.openadapt.ai](https://docs.openadapt.ai). + ## Development ```bash diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md new file mode 100644 index 0000000..af5e328 --- /dev/null +++ b/docs/DISTRIBUTION.md @@ -0,0 +1,233 @@ +# Distribution & discoverability — openadapt-agent + +**Status: Experimental (v2).** This document describes how the package is +made installable and discoverable as an MCP server, the security-relevant +distinction between the *public capability* and a *user's private +bundle*, and the exact founder-owned steps to publish and list it. The +publish/submission steps are **not automated here** — they mint public, +first-party identities and are outward-facing founder actions. + +## 1. The public capability vs the user's private artifact + +There are two very different things people mean by "an OpenAdapt MCP +server". Keep them separate. + +| | Public / official | Private / per-user | +| --- | --- | --- | +| **What it is** | the `openadapt-agent` package and its MCP server *program* | a user's compiled `openadapt-flow` workflow *bundle* | +| **What it exposes** | the OpenAdapt capability: inspect a compiled bundle, and (opt-in) run it under governance | one customer's specific recorded workflow (its steps, parameters, recorded example values) | +| **Where it lives** | PyPI + MCP registries (official, Smithery, mcp.so, Glama, PulseMCP) | the operator's own disk, passed at launch via `--bundles` | +| **Ships in the package?** | yes — code only | **never** — no bundle is embedded in the wheel, `server.json`, or any registry listing | + +**Design consequence:** the published server takes the bundle directory +as a launch-time argument (`--bundles `) and reads nothing about a +user's workflow at build or publish time. A compiled bundle can contain +recorded example values that are business- or PHI-sensitive (see the +workspace license/PHI rules); it is the operator's artifact and stays +inside their trust boundary. The registry listing advertises the +*capability*, not any workflow. + +**Read-only by default when registry-launched.** The `server.json` and +`smithery.yaml` launch the server WITHOUT `--allow-run`, so a one-click +install from a directory yields the inspection tools only +(`list_workflows`, `get_workflow`, `get_run_report`). Executing a +workflow (`run_`) is a deliberate operator act: they add +`--allow-run` (and typically `--policy`/`--config`) when they run the +server against their own bundles. This matches the security model in +[`DESIGN.md`](DESIGN.md). + +> There is intentionally **no** hosted, multi-tenant "official OpenAdapt +> workflow server" that exposes OpenAdapt-operated workflows to the +> public. v2 is stdio-only, single-user, local. A hosted control plane is +> a separate, proprietary surface (`openadapt-cloud`) and is out of scope +> for this package. + +## 2. Canonical registry metadata + +Every directory (official registry, Smithery, mcp.so, Glama, PulseMCP) +indexes the same core fields. Keep this block as the single source of +truth; the machine-readable copies are [`../server.json`](../server.json) +and [`../smithery.yaml`](../smithery.yaml). + +- **Name (reverse-DNS, official registry):** `io.github.OpenAdaptAI/openadapt-agent` +- **Display name:** OpenAdapt Agent (openadapt-flow bridge) +- **PyPI package:** `openadapt-agent` +- **Version:** `2.0.0.dev0` (Experimental; pick the final release version at publish — see step 3.1) +- **Description:** Expose compiled openadapt-flow workflow bundles as governed MCP tools (Experimental). +- **Homepage / docs:** https://docs.openadapt.ai +- **Repository:** https://github.com/OpenAdaptAI/openadapt-agent +- **License:** MIT +- **Transport:** stdio +- **Run command (uvx):** `uvx openadapt-agent serve --bundles [--allow-run]` +- **Config:** `--bundles` (required, dir), `--allow-run` (opt-in execution), `OPENADAPT_BUNDLE_KEY` (optional secret, for encrypted bundles) +- **Tools:** + - `list_workflows` — list exposed bundles (slug, name, params, step count, load errors). + - `get_workflow` — inspect one workflow (step intents, declared params with recorded example values, certification status). + - `get_run_report` — fetch the persisted `report.json` for a prior run (halt/success evidence). + - `run_` — execute a bundle via the governed `openadapt-flow run` CLI (only when `--allow-run`). Returns `success` | `halt` | `refused` | `timeout` | `error`. +- **Categories/tags:** mcp, agent-skills, automation, workflow, gui, governed, healthcare, rpa + +## 3. Release automation vs. founder one-time actions + +The build-and-publish pipeline is **automated** in +[`.github/workflows/release.yml`](../.github/workflows/release.yml). What +remains for the founder is a small set of **one-time identity/config +actions** (create accounts, enable Trusted Publishing, claim directory +listings) that mint public first-party identities and therefore stay +founder-authorized. Nothing publishes to a public index automatically +except on a deliberate version tag / GitHub Release. + +### 3.0 What the workflow does (AUTOMATED) + +| Trigger | Jobs that run | Publishes? | +| --- | --- | --- | +| Pull request touching release files, or `workflow_dispatch`, or any tag/release | `validate` (build, license/boundary check on the built archives, `twine check`, `server.json` schema validation) | **No** — dry run | +| Push a `vX.Y.Z` tag, or publish a GitHub Release | `validate` -> `pypi-publish` -> `mcp-registry-publish` | **Yes** | + +- **`validate`** builds the sdist+wheel, runs + [`scripts/check_release_artifacts.py`](../scripts/check_release_artifacts.py) + (fails if a wheel/sdist carries a bundle, `.enc`, run outputs, keys, or + any non-code payload — the license/boundary gate), runs `twine check`, + validates `server.json` against its live schema, and runs the + version-consistency guard (`tests/test_distribution.py`). It runs on PRs + and manual dispatch so the pipeline is testable **without** publishing. +- **`pypi-publish`** (tag/release only) asserts the tag matches the + package version, then uploads via **PyPI Trusted Publishing (OIDC)** — + no long-lived token. Runs in the `pypi` GitHub environment (add required + reviewers there if you want a human approval gate on every publish). +- **`mcp-registry-publish`** (tag/release only) waits for the new version + to be live on PyPI (the registry validates package existence), then + `mcp-publisher login github-oidc` + `mcp-publisher publish` — no token, + because the repo lives under the `OpenAdaptAI` org that owns the + `io.github.OpenAdaptAI` namespace. + +To cut a release: bump the three version fields (see §3.1), merge, then +`git tag vX.Y.Z && git push origin vX.Y.Z` (or publish a Release with that +tag). The first tag you push IS the first real publish — do it +deliberately. + +### 3.a Required repo configuration and secrets (FOUNDER, one-time) + +**In the OIDC path (recommended) there are ZERO repo secrets.** You must +do this one-time setup before the first tag: + +1. **PyPI Trusted Publishing** — on https://pypi.org, register a pending + publisher for the (not-yet-existent) project `openadapt-agent`: + owner `OpenAdaptAI`, repo `openadapt-agent`, workflow `release.yml`, + environment `pypi`. (PyPI account with 2FA required; the project is + created on first OIDC upload.) +2. **GitHub environment `pypi`** — create it in repo Settings -> + Environments. Optionally add required reviewers so each publish waits + for a human click. +3. **MCP registry** — nothing. `io.github.OpenAdaptAI/*` is authorized by + the repo's own GitHub OIDC identity. + +**Optional token fallbacks** (only if you deliberately opt out of OIDC): + +| If you set repo variable... | ...you MUST set repo secret | Used for | +| --- | --- | --- | +| `PYPI_PUBLISH_METHOD = token` | `PYPI_API_TOKEN` | PyPI upload via API token | +| `MCP_PUBLISH_METHOD = token` | `MCP_GITHUB_TOKEN` (PAT, `read:org`+`read:user`) | MCP registry `login github --token` | + +The workflow **fails loud, naming the missing secret**, if a token method +is selected but its secret is absent — it never silently skips a publish. +No secret is ever committed; these live only in repo Settings -> Secrets. + +### 3.1 Decide the release version + +`2.0.0.dev0` is a dev pre-release; `pip install openadapt-agent` will not +select it without `--pre`. For a real launch choose one of: +- `2.0.0rc1` (recommended first public: pre-release, opt-in via `--pre`), or +- `2.0.0` (final; drop the Experimental-only caveats only when honest to). + +Whatever you choose, set it in **three places that a CI test pins +together**: `pyproject.toml` `version`, `src/openadapt_agent/__init__.py` +`__version__`, and both `version` fields in `server.json`. The +`tests/test_distribution.py` guard fails if they drift. + +### 3.2 Publish to PyPI — AUTOMATED (`pypi-publish` job) + +Fires automatically on a `vX.Y.Z` tag / Release once §3.a step 1-2 are +done. To reproduce locally (dry run or a manual emergency publish): + +```bash +python -m build # sdist + wheel into dist/ +python scripts/check_release_artifacts.py # license/boundary gate +twine check dist/* +# twine upload dist/* # emergency manual path only; prefer the tag flow +``` + +Verify after a release: `pip index versions openadapt-agent` shows the new +version and `uvx openadapt-agent@ --version` prints it. + +### 3.3 Submit to the official MCP registry — AUTOMATED (`mcp-registry-publish` job) + +Fires automatically after the PyPI job, using `mcp-publisher login +github-oidc` (no secret; the `OpenAdaptAI` org owns the namespace). The +job waits for the PyPI version to be installable first, because the +registry validates package existence. Local equivalent: + +```bash +mcp-publisher login github-oidc # or: login github (interactive OAuth) +mcp-publisher publish # reads ./server.json +``` + +If the registry asks you to prove the PyPI package belongs to this server, +add the ownership marker it names to the package metadata and re-run. + +### 3.4 List on Smithery — MANUAL (one-time claim) + +`smithery.yaml` is already at the repo root. +- Sign in at https://smithery.ai with the GitHub org account. +- "Add Server" -> point at `github.com/OpenAdaptAI/openadapt-agent`. +- Smithery reads `smithery.yaml` (stdio `startCommand`, `configSchema`, + `commandFunction`). Confirm the generated config form shows + `bundlesDir` (required), `allowRun` (default off), `bundleKey` (secret). +- Publish. Smithery hosts the connection config; it does not host bundles. + +> **Why manual:** Smithery has no public "create listing" API keyed to an +> external repo; the initial claim is an authenticated GitHub-org action. +> Once claimed, Smithery re-reads `smithery.yaml` from the repo on each +> push, so subsequent config changes ARE automatic. + +### 3.5 mcp.so — MANUAL (one-time submission) + +- Submit at https://mcp.so (Submit / "Add MCP Server") with the repo URL. +- It scrapes the README + `server.json`; ensure the "How results come + back" and tool table render. No account action beyond the submission + form. + +### 3.6 Glama — MOSTLY AUTOMATIC (claim to control metadata) + +- Glama (https://glama.ai/mcp/servers) auto-indexes public GitHub MCP + servers and re-scores on push. Claim the server with the GitHub org + account to control metadata, and confirm the MIT license + repo + metadata are picked up. + +### 3.7 PulseMCP — MANUAL (one-time submission) + +- Submit at https://www.pulsemcp.com (their "Submit a server" flow) with + the repo + PyPI package. PulseMCP indexes description, install command, + and tool list from this doc's canonical block. + +### 3.8 Agent Skills directories — MANUAL (first-party listing) + +The Agent Skill this package emits (`openadapt-agent emit-skill`) is a +per-bundle artifact a user generates locally; it embeds a copy of *their* +bundle, so it is **not** something to publish centrally on their behalf. +For discoverability of the *capability*: +- Add openadapt-agent to any first-party "awesome MCP / Agent Skills" + list OpenAdapt maintains, and to the `OpenAdaptAI/OpenAdapt` README's + integrations section. +- Do not upload user-emitted skill folders to a public skills directory: + a skill folder contains the compiled bundle (potentially sensitive + recorded values). Publishing one is the user's decision, per the PHI / + artifact-egress rules. + +## 4. Post-publish consistency checklist + +- [ ] `uvx openadapt-agent serve --bundles ` starts read-only for a fresh installer. +- [ ] `server.json` version == PyPI version == `__version__` (CI-pinned). +- [ ] Registry descriptions match the canonical metadata block (§2). +- [ ] README badges point at the real PyPI project once published. +- [ ] docs.openadapt.ai links back to this repo (add an "Agents & MCP" page — see the OpenAdapt docs repo; that deploy is a separate founder action). diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..9effad0 --- /dev/null +++ b/llms.txt @@ -0,0 +1,34 @@ +# openadapt-agent + +> The agent-facing bridge for [openadapt-flow](https://github.com/OpenAdaptAI/openadapt-flow). It exposes compiled workflow bundles to other agents (Claude Code, Claude Desktop, any MCP client) as an **MCP server** and as **Agent Skills**. Every execution shells out to the governed `openadapt-flow run` CLI, so flow's fail-closed admission gates (certification, identity arming, effect contracts, encryption at rest, integrity pinning) cannot be bypassed. A halted or refused run is always returned as a structured halt/refusal with an evidence pointer, never as a fabricated success. Status: Experimental (v2). + +## What it provides + +- `openadapt-agent serve --bundles [--allow-run]`: an MCP server (stdio) over a directory of compiled bundles. Tools: `list_workflows`, `get_workflow`, `get_run_report` (always on, read-only) and `run_` (only when the operator passes `--allow-run`), whose JSON input schema is derived from the bundle's declared parameters. +- `openadapt-agent emit-skill --out `: a Claude Agent Skill folder for one bundle. Wraps flow's own `emit-skill` and appends MCP-invocation and halt-semantics guidance. + +## Run outcomes (honesty invariant) + +- `success`: exit 0 AND the persisted `report.json` marks the run successful. Only then did the workflow complete. +- `halt`: the run executed and stopped at an unhandled state (halt instead of guessing). NOT a success; evidence in `report.json`/`REPORT.md`. +- `refused`: a governed admission gate refused the bundle before execution; nothing ran. +- `timeout` / `error`: killed at the deadline / infrastructure problem. + +## Public capability vs private artifact + +- The **package/MCP server is the public, official OpenAdapt capability**: inspect and (opt-in) run a compiled bundle under governance. It is what gets published to PyPI and listed in MCP registries. +- A user's **compiled workflow bundle is their private artifact**. It is supplied at server start via `--bundles` and is never embedded in the package, the `server.json`, or any registry listing. + +## Key documents + +- [README](https://github.com/OpenAdaptAI/openadapt-agent/blob/main/README.md): install, quickstart, honest limits. +- [docs/DESIGN.md](https://github.com/OpenAdaptAI/openadapt-agent/blob/main/docs/DESIGN.md): architecture, security model, what v2 deliberately does not do. +- [docs/DISTRIBUTION.md](https://github.com/OpenAdaptAI/openadapt-agent/blob/main/docs/DISTRIBUTION.md): registry artifacts, the public-vs-private-server distinction, and the submission plan. +- [server.json](https://github.com/OpenAdaptAI/openadapt-agent/blob/main/server.json): official MCP registry manifest. +- [smithery.yaml](https://github.com/OpenAdaptAI/openadapt-agent/blob/main/smithery.yaml): Smithery launch config. + +## Related + +- [openadapt-flow](https://github.com/OpenAdaptAI/openadapt-flow): the governed demonstration compiler and runtime this package bridges. +- [OpenAdapt](https://github.com/OpenAdaptAI/OpenAdapt): the umbrella project. +- [docs.openadapt.ai](https://docs.openadapt.ai): OpenAdapt documentation. diff --git a/pyproject.toml b/pyproject.toml index f13da63..02ba633 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,8 @@ dependencies = [ dev = [ "pytest>=8.0.0", "ruff>=0.4.0", + # Parses smithery.yaml in the distribution-artifact guard tests. + "pyyaml>=6.0", ] [project.scripts] diff --git a/scripts/check_release_artifacts.py b/scripts/check_release_artifacts.py new file mode 100644 index 0000000..34bb7ab --- /dev/null +++ b/scripts/check_release_artifacts.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""License-hygiene / package-boundary gate for the built release archives. + +Enforces the workspace release rule (AGENTS.md 4.1 / 4.2): a public, +MIT-licensed wheel/sdist must ship code only and must never carry a user's +compiled workflow bundle, an encrypted artifact, run outputs, secrets, or +any other data/crown-jewel material. This inspects the ACTUAL built +archives in ``dist/`` (not the source tree), because that is the only +thing that gets published. + +Usage:: + + python -m build + python scripts/check_release_artifacts.py # checks ./dist + python scripts/check_release_artifacts.py path/to/dist + +Exits nonzero (loudly) on the first violation. +""" + +from __future__ import annotations + +import sys +import tarfile +import zipfile +from pathlib import Path + +# Substrings that must never appear in a published archive member path. +# Guards against accidentally vendoring a user's bundle, an encrypted +# artifact, run evidence, or a credential file into the wheel/sdist. +# Precise path fragments (not broad words like "secret", which would flag +# a legitimately-named workflow file such as secret-scan.yml). +FORBIDDEN_SUBSTRINGS = ( + "workflow.json", # a compiled bundle (user's private artifact) + ".enc", # encrypted-at-rest bundle + "/bundle/", # a copied bundle directory + "/runs/", # run outputs (may contain live observations / PHI) + "/.env", + "id_rsa", + "id_ed25519", + ".pem", + ".sqlite", + ".db", +) + +# The wheel must contain ONLY the import package and its metadata. +ALLOWED_WHEEL_PREFIXES = ("openadapt_agent/",) +# Extensions allowed inside the wheel's import package (code + typing only). +ALLOWED_WHEEL_SUFFIXES = (".py", ".pyi", ".typed") + + +def _wheel_members(path: Path) -> list[str]: + with zipfile.ZipFile(path) as zf: + return [n for n in zf.namelist() if not n.endswith("/")] + + +def _sdist_members(path: Path) -> list[str]: + with tarfile.open(path, "r:gz") as tf: + return [m.name for m in tf.getmembers() if m.isfile()] + + +def _check_forbidden(archive: str, members: list[str]) -> list[str]: + problems = [] + for name in members: + lowered = name.lower() + for bad in FORBIDDEN_SUBSTRINGS: + if bad in lowered: + problems.append(f"{archive}: forbidden member {name!r} (matched {bad!r})") + return problems + + +def _check_wheel_is_code_only(archive: str, members: list[str]) -> list[str]: + problems = [] + saw_license = False + for name in members: + if ".dist-info/" in name: + base = name.rsplit("/", 1)[-1] + if base.startswith("LICENSE") or base == "METADATA": + saw_license = saw_license or base.startswith("LICENSE") + continue + if not name.startswith(ALLOWED_WHEEL_PREFIXES): + problems.append(f"{archive}: unexpected top-level member {name!r} (wheel must be code only)") + continue + if not name.endswith(ALLOWED_WHEEL_SUFFIXES): + problems.append( + f"{archive}: non-code file {name!r} in wheel " + f"(allowed suffixes: {ALLOWED_WHEEL_SUFFIXES})" + ) + if not saw_license: + problems.append(f"{archive}: no LICENSE file found in .dist-info (MIT notice required)") + return problems + + +def main(argv: list[str]) -> int: + dist_dir = Path(argv[1]) if len(argv) > 1 else Path("dist") + if not dist_dir.is_dir(): + print(f"ERROR: dist directory not found: {dist_dir}", file=sys.stderr) + return 2 + + wheels = sorted(dist_dir.glob("*.whl")) + sdists = sorted(dist_dir.glob("*.tar.gz")) + if not wheels or not sdists: + print( + f"ERROR: expected at least one wheel and one sdist in {dist_dir} " + f"(found {len(wheels)} wheel(s), {len(sdists)} sdist(s)); run `python -m build` first.", + file=sys.stderr, + ) + return 2 + + problems: list[str] = [] + for wheel in wheels: + members = _wheel_members(wheel) + problems += _check_forbidden(wheel.name, members) + problems += _check_wheel_is_code_only(wheel.name, members) + for sdist in sdists: + members = _sdist_members(sdist) + problems += _check_forbidden(sdist.name, members) + + if problems: + print("RELEASE ARTIFACT CHECK FAILED:", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + return 1 + + print( + f"Release artifact check passed: {len(wheels)} wheel(s) + {len(sdists)} " + f"sdist(s) in {dist_dir} are code-only and carry no bundle/secret/data material." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/server.json b/server.json new file mode 100644 index 0000000..6eac93a --- /dev/null +++ b/server.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.OpenAdaptAI/openadapt-agent", + "title": "OpenAdapt Agent (openadapt-flow bridge)", + "description": "Expose compiled openadapt-flow workflow bundles as governed MCP tools (Experimental).", + "websiteUrl": "https://docs.openadapt.ai", + "repository": { + "url": "https://github.com/OpenAdaptAI/openadapt-agent", + "source": "github" + }, + "version": "2.0.0.dev0", + "packages": [ + { + "registryType": "pypi", + "registryBaseUrl": "https://pypi.org", + "identifier": "openadapt-agent", + "version": "2.0.0.dev0", + "runtimeHint": "uvx", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "positional", + "valueHint": "subcommand", + "value": "serve", + "description": "Serve a directory of compiled bundles over MCP (stdio)." + }, + { + "type": "named", + "name": "--bundles", + "valueHint": "bundles_dir", + "format": "filepath", + "isRequired": true, + "description": "Directory of compiled openadapt-flow bundles (one bundle, or a directory whose immediate subdirectories are bundles). This is the operator's own private artifact and is never shipped with the package." + } + ], + "environmentVariables": [ + { + "name": "OPENADAPT_BUNDLE_KEY", + "description": "Decryption key for encrypted-at-rest bundles (workflow.json.enc). Omit for plaintext bundles.", + "isRequired": false, + "isSecret": true + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "notes": "Read-only by default. The server registers execution (run_*) tools only when the operator adds the --allow-run flag; a registry-launched install stays inspection-only unless that flag is appended. See docs/DISTRIBUTION.md." + } + } +} diff --git a/smithery.yaml b/smithery.yaml new file mode 100644 index 0000000..5f7561a --- /dev/null +++ b/smithery.yaml @@ -0,0 +1,52 @@ +# Smithery configuration for openadapt-agent (Experimental v2). +# +# Exposes compiled openadapt-flow workflow bundles as governed MCP tools +# over stdio. The bundle directory is the operator's OWN private artifact: +# Smithery collects its path/config at launch; nothing about a user's +# compiled workflow is baked into this package or its registry listing. +# +# Reference: https://smithery.ai/docs/build/project-config/smithery.yaml +startCommand: + type: stdio + configSchema: + type: object + required: + - bundlesDir + properties: + bundlesDir: + type: string + title: Bundles directory + description: >- + Path to a directory of compiled openadapt-flow bundles (either a + single bundle, or a directory whose immediate subdirectories are + bundles). This is your own private artifact. + allowRun: + type: boolean + title: Allow execution (run_* tools) + default: false + description: >- + When false (default), only the read-only tools (list_workflows, + get_workflow, get_run_report) are registered. When true, each + loadable bundle also gets a run_ tool that executes through + the governed `openadapt-flow run` CLI (fail-closed admission + gates). A halt or refusal is always surfaced as such, never as a + fabricated success. + bundleKey: + type: string + title: Bundle decryption key + description: >- + Decryption key for encrypted-at-rest bundles (workflow.json.enc). + Leave empty for plaintext bundles. Passed as OPENADAPT_BUNDLE_KEY. + commandFunction: |- + (config) => ({ + command: 'openadapt-agent', + args: [ + 'serve', + '--bundles', config.bundlesDir, + ...(config.allowRun ? ['--allow-run'] : []) + ], + env: config.bundleKey ? { OPENADAPT_BUNDLE_KEY: config.bundleKey } : {} + }) + exampleConfig: + bundlesDir: /path/to/compiled/bundles + allowRun: false diff --git a/tests/test_distribution.py b/tests/test_distribution.py new file mode 100644 index 0000000..418fa72 --- /dev/null +++ b/tests/test_distribution.py @@ -0,0 +1,122 @@ +"""Guard the distribution artifacts (server.json / smithery.yaml / llms.txt). + +These files are how the package is listed in MCP registries. The tests +pin them to the package's real identity so a version bump or a rename +cannot silently desync the registry manifests, and they encode the +security-relevant invariant that a registry-launched server is read-only +by default (execution requires the operator to add --allow-run). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import yaml + +from openadapt_agent import __version__ + +REPO_ROOT = Path(__file__).resolve().parents[1] +SERVER_JSON = REPO_ROOT / "server.json" +SMITHERY_YAML = REPO_ROOT / "smithery.yaml" +LLMS_TXT = REPO_ROOT / "llms.txt" +PYPROJECT = REPO_ROOT / "pyproject.toml" + +REVERSE_DNS_NAME = "io.github.OpenAdaptAI/openadapt-agent" +PYPI_NAME = "openadapt-agent" + + +def _pyproject_version() -> str: + text = PYPROJECT.read_text(encoding="utf-8") + match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text) + assert match, "could not find version in pyproject.toml" + return match.group(1) + + +def _server_json() -> dict: + return json.loads(SERVER_JSON.read_text(encoding="utf-8")) + + +def test_server_json_is_valid_and_well_formed() -> None: + doc = _server_json() + assert doc["$schema"].startswith("https://static.modelcontextprotocol.io/") + assert doc["name"] == REVERSE_DNS_NAME + assert doc["repository"]["url"].endswith("/openadapt-agent") + assert doc["repository"]["source"] == "github" + packages = doc["packages"] + assert len(packages) == 1 + pkg = packages[0] + assert pkg["registryType"] == "pypi" + assert pkg["identifier"] == PYPI_NAME + assert pkg["runtimeHint"] == "uvx" + assert pkg["transport"]["type"] == "stdio" + + +def test_version_is_consistent_everywhere() -> None: + """server.json (twice), pyproject, and __version__ must not drift.""" + doc = _server_json() + assert doc["version"] == __version__ + assert doc["packages"][0]["version"] == __version__ + assert _pyproject_version() == __version__ + + +def test_serve_is_the_subcommand_and_bundles_is_required() -> None: + args = _server_json()["packages"][0]["packageArguments"] + positional = [a for a in args if a["type"] == "positional"] + named = {a["name"]: a for a in args if a["type"] == "named"} + # The published launch command is `openadapt-agent serve --bundles `. + assert any(a.get("value") == "serve" for a in positional) + assert "--bundles" in named + assert named["--bundles"]["isRequired"] is True + + +def test_registry_launch_is_read_only_by_default() -> None: + """A one-click registry install must NOT auto-enable execution. + + --allow-run is deliberately absent from server.json/smithery defaults; + the operator opts into run_* tools explicitly. + """ + args = _server_json()["packages"][0]["packageArguments"] + assert not any( + a.get("name") == "--allow-run" or a.get("value") == "--allow-run" + for a in args + ) + smithery = yaml.safe_load(SMITHERY_YAML.read_text(encoding="utf-8")) + props = smithery["startCommand"]["configSchema"]["properties"] + assert props["allowRun"]["default"] is False + # --allow-run is not forced into the required config. + assert "allowRun" not in smithery["startCommand"]["configSchema"].get( + "required", [] + ) + + +def test_bundle_key_is_marked_secret_not_leaked() -> None: + env = _server_json()["packages"][0].get("environmentVariables", []) + key = next(e for e in env if e["name"] == "OPENADAPT_BUNDLE_KEY") + assert key["isSecret"] is True + assert key.get("isRequired", False) is False + + +def test_smithery_stdio_command_wires_bundles_and_allow_run() -> None: + smithery = yaml.safe_load(SMITHERY_YAML.read_text(encoding="utf-8")) + start = smithery["startCommand"] + assert start["type"] == "stdio" + assert "bundlesDir" in start["configSchema"]["required"] + command_fn = start["commandFunction"] + assert "openadapt-agent" in command_fn + assert "--bundles" in command_fn + assert "--allow-run" in command_fn + assert "OPENADAPT_BUNDLE_KEY" in command_fn + + +def test_llms_txt_lists_the_tool_surface() -> None: + text = LLMS_TXT.read_text(encoding="utf-8") + for token in ( + "list_workflows", + "get_workflow", + "get_run_report", + "run_", + "docs.openadapt.ai", + ): + assert token in text