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